The Next.js App Router Explained
- nextjs
- react
- typescript
- webdev
- softwarearchitecture
- frontend
- serverless
In the last article in this series, Error Boundaries and Suspense, we looked at how React contains failure and handles asynchronous loading states at the component level. The App Router is where those primitives stop being isolated patterns and become the framework's default way of building a page. If you've only worked with the Pages Router, or you skipped straight to App Router without understanding what problem it's solving, this is the article that connects the dots.
This is part of the React & Next.js Complete Guide series, and it assumes you're comfortable with Server Components conceptually. What I want to cover here is the App Router's actual mental model: the file conventions, the rendering boundaries, the caching behavior that trips people up, and where I'd push back on using every feature it offers just because it's there.
I'll be upfront about the tradeoff at the center of this: the App Router gives you more control over rendering granularity than the Pages Router ever did, and it asks you to think harder about where your code runs in exchange. For a lot of teams, that's a fair trade. For some, it's added complexity they didn't need yet.
File conventions replace getServerSideProps and friends
The Pages Router used exported functions, getServerSideProps, getStaticProps, getInitialProps, to tell Next.js how a page should be rendered. The App Router replaces that with file-based conventions inside the app/ directory, and the shift is bigger than it looks.
app/
layout.tsx // Root layout, wraps every route
page.tsx // Route for "/"
loading.tsx // Suspense fallback for this segment
error.tsx // Error boundary for this segment
dashboard/
layout.tsx // Nested layout, wraps everything under /dashboard
page.tsx // Route for "/dashboard"
settings/
page.tsx // Route for "/dashboard/settings"
Each folder is a route segment, and each segment can define its own layout.tsx, loading.tsx, error.tsx, and not-found.tsx. Layouts nest: the root layout wraps everything, dashboard/layout.tsx wraps everything under /dashboard, and so on. This matters in practice because layouts don't re-render on navigation between their child routes. If your dashboard layout holds a sidebar and a persistent websocket connection, navigating between /dashboard/settings and /dashboard/billing doesn't remount that layout. That's a real architectural improvement over the Pages Router, where a shared layout was usually a component you manually wrapped every page in, with no built-in guarantee it wouldn't remount.
The pitfall here is treating every folder as if it needs its own loading.tsx and error.tsx. Each one you add creates a Suspense and error boundary at that point in the tree, which is useful when a route segment does independent data fetching that shouldn't block its parent. Adding it reflexively at every level, though, means a single slow request now shows a loading skeleton three levels deep instead of a clean page-level state, and users end up watching multiple spinners fire in sequence. Add these boundaries where you have an actual reason: a segment that fetches something slower or less reliable than the rest of the page.
Server Components are the default, and that's a rendering decision, not a syntax one
Every component under app/ is a Server Component unless you mark it otherwise with "use client" at the top of the file. This is the single biggest mental shift from the Pages Router, where everything eventually ran on the client. In the App Router, a component that never needs interactivity, a page shell, a card that just displays data, a footer, doesn't need to ship any JavaScript to the browser at all.
// app/dashboard/page.tsx
import { getDashboardMetrics } from "@/lib/metrics";
import { MetricsChart } from "@/components/metrics-chart";
export default async function DashboardPage() {
const metrics = await getDashboardMetrics();
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<MetricsChart data={metrics} />
</div>
);
}
// components/metrics-chart.tsx
"use client";
import { useState } from "react";
type MetricsChartProps = {
data: { label: string; value: number }[];
};
export function MetricsChart({ data }: MetricsChartProps) {
const [range, setRange] = useState<"7d" | "30d">("7d");
return (
<div>
<button onClick={() => setRange(range === "7d" ? "30d" : "7d")}>
{range === "7d" ? "Last 7 days" : "Last 30 days"}
</button>
{/* chart rendering using data and range */}
</div>
);
}
DashboardPage runs on the server, awaits data directly in the component body, and never ships its own code to the browser. MetricsChart needs useState, so it's a Client Component, and its JavaScript does get sent down. This is the actual value proposition: the server-or-client choice happens per component instead of once for the whole page, and the framework composes the result for you.
The pitfall that catches almost everyone at some point: adding "use client" to a file doesn't just make that component a Client Component, it makes every component it imports a Client Component too, because the boundary is drawn at the import graph, not the individual function. I've seen a single "use client" directive on a layout file accidentally pull an entire component tree, including things that had no business running in the browser, into the client bundle. Keep "use client" boundaries as low in the tree as possible: wrap the specific interactive piece, and pass data into it as props from a Server Component parent, rather than marking a whole page client-side because one button needs onClick.
Data fetching and caching are coupled, and that's worth understanding before you ship
In the App Router, fetch is patched by Next.js to participate in a caching layer, and the caching behavior differs depending on how you call it. This is genuinely useful once you understand it and genuinely confusing the first time you hit it in production.
// Cached indefinitely (until manually revalidated or a deploy)
const staticData = await fetch("https://api.example.com/plans");
// Never cached, always a fresh network request
const liveData = await fetch("https://api.example.com/orders", {
cache: "no-store",
});
// Cached, but revalidated in the background after 60 seconds
const revalidatedData = await fetch("https://api.example.com/pricing", {
next: { revalidate: 60 },
});
The default caching behavior existed to support static generation at scale, so pages that don't need per-request freshness don't hit your origin on every request. The tradeoff is that it's easy to assume a fetch call is live when it's actually serving a cached response, especially if you copy a data-fetching pattern from one route to another without checking its caching options. I'd treat cache: "no-store" as the safe, explicit default for anything user-specific or frequently changing: orders, account data, anything behind auth. Only reach for the default caching or revalidate options once you've confirmed the data is genuinely safe to serve stale for a window of time.
Route Handlers (the app/api/*/route.ts convention that replaces API routes from the Pages Router) follow the same caching rules for GET requests, which surprises people who expect an API endpoint to always be live:
// app/api/orders/route.ts
import { NextResponse } from "next/server";
import { getOrdersForUser } from "@/lib/orders";
import { getSessionUser } from "@/lib/auth";
export async function GET(request: Request) {
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orders = await getOrdersForUser(user.id);
return NextResponse.json(orders);
}
This particular handler is dynamic by nature since it reads the request and depends on the authenticated user, so Next.js won't statically cache it. A GET handler with no request-dependent logic, though, can be cached the same way a page can. That's either a useful optimization or a nasty surprise, depending on whether you intended it.
Streaming and Suspense change how you think about "the page load"
The App Router's Server Components integrate directly with React Suspense, which means a slow piece of data no longer has to block the entire page from rendering. You wrap the slow part in <Suspense>, give it a fallback, and Next.js streams the rest of the page to the browser while that piece resolves.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentActivity } from "@/components/recent-activity";
import { ActivitySkeleton } from "@/components/activity-skeleton";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
Here, RecentActivity is an async Server Component that fetches its own data. If that fetch is slow, the rest of the dashboard still renders and reaches the browser immediately, with the activity skeleton in its place until the real content is ready. This is a meaningfully different model from the Pages Router, where a slow getServerSideProps call blocked the entire response.
The tradeoff worth naming: streaming adds moving parts to your mental model of "when did the page finish loading." For a marketing site or a simple CRUD dashboard, the Pages Router's all-or-nothing model was easier to reason about and debug. Streaming earns its complexity on pages with genuinely uneven data-fetching costs, a fast primary query next to a slow secondary one, rather than as a default applied everywhere out of habit.
Key takeaways
- File conventions (`layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`) replace the exported data-fetching functions from the Pages Router, and layouts persist across navigations within the same segment instead of remounting.
- Server Components are the default in `app/`; add `"use client"` only to the specific interactive piece, since the directive applies to the whole import subtree, not just one function.
- `fetch` caching in the App Router is opt-out, not opt-in. Use `cache: "no-store"` explicitly for anything user-specific or frequently changing, and only rely on the default caching once you've confirmed the data can safely go stale.
- Route Handlers follow the same caching rules as pages for GET requests, which means a handler with no request-dependent logic can be statically cached unless you tell it otherwise.
- Suspense boundaries let slow data streams in without blocking the rest of the page, but add them where a segment's fetch cost genuinely differs from the rest of the page, not at every folder by default.
- The App Router trades the Pages Router's simpler, more uniform model for finer-grained control over rendering and caching. That control is worth having once your app's data-fetching patterns are uneven enough to need it.
Frequently asked questions
Do I need to migrate an existing Pages Router app to the App Router right away?
No. Next.js supports both routers in the same project, and a gradual migration route by route is a reasonable path for an app that's already in production. Migrating everything at once, purely to be on the newer convention, isn't a good use of engineering time without a concrete reason.
Why is my Server Component re-fetching data on every navigation instead of caching it?
Check the `cache` and `next.revalidate` options on your `fetch` calls. If you didn't set them, you're getting the default caching behavior, which may not match what you expect depending on the request. For anything tied to the current user's session, `cache: "no-store"` is usually what you actually want.
Can I use `useState` or `useEffect` inside a Server Component?
No. Server Components run only on the server and have no hooks tied to client-side interactivity or lifecycle. Anything that needs `useState`, `useEffect`, event handlers, or browser APIs has to live in a Client Component, marked with `"use client"`.
What's the difference between `loading.tsx` and wrapping a component in `<Suspense>` manually?
`loading.tsx` automatically wraps the entire route segment's `page.tsx` in a Suspense boundary at the routing level. Manually placed `<Suspense>` boundaries inside a page give you finer control, letting part of the page render immediately while only a specific slow piece streams in later.
Does the App Router mean I can't use client-side data fetching libraries like a query cache anymore?
You still can, and for data that changes frequently based on user interaction, like pagination or filtering after the initial page load, a client-side fetching layer alongside your Client Components is often the right call. The App Router changes how the initial page loads; it doesn't remove the need for client-side data management inside interactive components.
Why did my client bundle size grow after I added `"use client"` to a shared layout file?
Because that directive marks every component imported into that file as part of the client bundle, not just the layout itself. Move the directive down to the smallest component that actually needs interactivity, and keep everything else, especially anything imported by many routes, as a Server Component.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.