Under the Pages Router, every file in pages/ is a React component that ships to the browser, hydrates, and runs there. The server's job is to produce the first HTML snapshot and hand over the JavaScript; after that, your app is a client-side application. Data fetching is bolted on through two special exported functions — getServerSideProps for per-request data, getStaticProps for build-time data — which run outside your component tree and pass their results in as props. And because those props have to reach a component that will re-run in the browser, everything you return is serialized to JSON, embedded in the HTML, and parsed again on the client. Your data crosses the wire twice: once as rendered markup, once as the props blob that recreates it.
That design has a constraint that quietly shapes everything you build: only pages can fetch server data. A comment list four components deep cannot ask for its own comments. Either the page fetches them and drills the props down, or the component fetches client-side in useEffect after it mounts. Both options get worse as the app grows. Prop drilling turns your page files into data-plumbing hubs, and client-side fetching creates the waterfall: render the page, fetch, render the children, they fetch, render the grandchildren, they fetch — each round trip waiting for the previous one to finish.
import type { GetServerSideProps } from "next";
import { getPost } from "@/lib/posts";
type Props = { post: { title: string; body: string; publishedAt: string } };
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
const post = await getPost(String(ctx.params?.slug));
if (!post) return { notFound: true };
// Props must be JSON-serializable: the Date from the database
// has to become a string here and be re-parsed in the component.
return {
props: { post: { ...post, publishedAt: post.publishedAt.toISOString() } },
};
};
export default function PostPage({ post }: Props) {
return <article>{post.title}</article>;
}There is a second cost that never throws an error, so teams usually discover it in a Lighthouse report. Everything a page imports goes into that page's JavaScript bundle, because the whole tree has to hydrate in the browser. The markdown renderer, the date library, the syntax highlighter that only formats text and never handles a click — all of it ships, parses, and executes on your reader's phone, just to reproduce HTML the server already produced once. You can fight this with dynamic imports and dependency audits, but you are working around the model, not with it.
The App Router's answer is to make the server the default location for every component. A page in app/ is a React Server Component: it runs on the server, it can be async, and it fetches data with a plain await right where the data is used. No special exported function, no props contract at the page boundary, no serialization dance with your own database rows. When a component needs interactivity — state, effects, event handlers — you mark it with the "use client" directive, and from there down it behaves like every React component you have written for years. The question stops being "how do I get data into this page" and becomes "which parts of this page actually need to be a program in the browser". For most content-driven apps, the honest answer is: surprisingly few.
This is the part most comparisons undersell. Server Components are not a rendering optimization bolted onto a router; they are a different contract about where code lives, and they deserve their own article — I wrote one in React Server Components, explained. For this article, the consequence that matters is colocation: any component, at any depth, can own its own data.
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
import { PostBody } from "./post-body";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug); // a real Date comes back, stays a Date
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<PostBody content={post.body} />
</article>
);
}The comment list four levels deep now fetches its own comments, and the page neither knows nor cares. React deduplicates identical requests within a render pass, so two components asking for the same user do not trigger two queries. And because server components never ship to the browser, the markdown renderer and the syntax highlighter stay on the server where they always belonged. The client bundle contains your interactive islands and nothing else.
The Next.js and React teams did not do this because new is shiny. Hydration cost grew with application size and there was no way off the curve: every feature shipped more JavaScript, and the browser re-executed the entire tree to recreate what the server had already rendered. The deeper fix was to move the data boundary. In the Pages Router, the page is the unit of data fetching — one route, one props blob, everything flows down. In the App Router, the component is: it declares what it needs, fetches it, and renders, and composing components composes their data requirements automatically. That is the same shift React made for UI a decade earlier — composition over orchestration — finally applied to data.
The Pages Router has no real concept of a layout. The conventions — wrap everything in _app.tsx, or attach a getLayout function to each page — work, but the layout still re-renders on navigation, refetches any data it owns, and loses component state unless you engineer around it. The App Router makes layouts a first-class file: layout.tsx wraps every route below it, nests as deep as your folders do, and does not re-mount when you navigate between its children. A dashboard sidebar fetches the user once and simply persists across every dashboard page. If you have ever watched a sidebar flicker and refetch on every click, that is the entire pitch.
Streaming is the other structural change. Because the server owns rendering, it can send the finished parts of a page while the slow parts are still being computed. Under the Pages Router, getServerSideProps is all-or-nothing: the user stares at a blank tab until your slowest query resolves, and your fastest content waits for your slowest. The App Router lets you draw the line yourself with Suspense — the shell goes out immediately, and the slow section streams in when its data arrives.
This is also what loading.tsx actually does, because the file is widely misread as a spinner convention. Drop a loading.tsx next to a page.tsx and Next.js wraps that page in a Suspense boundary with your file as the fallback. The practical effect is that navigation feels instant: click a link and the layout plus your skeleton render immediately while the page's data resolves in the background. It is deliberately coarse — one boundary around the whole page — so for anything finer you place Suspense by hand around the specific slow component:
import { Suspense } from "react";
import { Comments } from "./comments";
import { CommentsSkeleton } from "./skeletons";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug); // fast — the page waits for this
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={post.id} /> {/* slow — streams in later */}
</Suspense>
</article>
);
}info
Streaming needs a host that keeps the response open and flushes HTML progressively. Vercel and most modern Node hosts handle this; some older reverse proxies buffer the entire response and silently turn your streaming page back into all-or-nothing. Verify it on your infrastructure before you design around it.
The early App Router cached aggressively by default: fetch calls were cached unless you opted out, rendered routes were cached, and the browser kept its own router cache with its own expiry. Several layers, mostly invisible, all switched on. The result was the defining frustration of the 2023–2024 era: you changed data, revalidated, redeployed, and the page still showed the old value — and you could not tell which of the layers was lying to you. People did not abandon the App Router because server components were too hard. They abandoned it because their CMS edits would not show up.
It settled. Next.js 15 flipped the defaults — fetch is no longer cached unless you ask — and the direction since has been explicit, visible caching: you mark what should be cached instead of discovering what secretly was. If you tried the App Router in its first year, got burned, and wrote the whole thing off, that specific complaint has been addressed. But take the lesson with you, because it outlives any particular release:
warning
Do not memorize the caching defaults of whichever Next.js version you happen to be on. Be explicit. Mark cached data as cached and dynamic data as dynamic, and your app behaves the same across every upgrade.
The mental model is genuinely heavier, and pretending otherwise is how migration estimates go wrong. You are writing one application that executes in two environments, and the boundary between them carries rules: server components can render client components but not the reverse (passing children through is the escape hatch), props crossing the boundary must be serializable, and a useState in a server component is an error you will hit weekly during your first month. The Pages Router had one rule — everything ends up in the browser — and one rule is easier to hold in your head.
The vocabulary grew too. The Pages Router had pages, API routes, and two data functions. The App Router has server components, client components, nested layouts, route handlers (the new home of your API endpoints), server actions for mutations, plus the file conventions — loading, error, not-found. Each piece is reasonable on its own; together they are a real learning curve. The shape of everyday tasks shifted with them: authentication, for instance, moved from API-route callbacks toward route handlers and server-side session reads, which is why the NextAuth v5 guide spends as much time on where code runs as on configuration.
The ecosystem paid a churn tax as well. Libraries built on the assumption that everything renders in the browser — runtime CSS-in-JS, some state managers, anything touching window at import time — needed rewrites or workarounds. Tutorials and Stack Overflow answers split into two dialects that look similar and do not mix. Most of that churn is behind us in 2026, but you will still trip over a 2022 answer that no longer applies.
The single most important migration fact: both routers run in the same application at the same time. app/ and pages/ coexist, each route lives in one or the other, and you move route by route over weeks while shipping features the whole time. Nobody should be planning a big-bang rewrite, and if a consultant proposes one, ask harder questions.
What actually breaks when a route moves:
- next/router becomes next/navigation — a different API, and router.events is gone, so anything listening for route changes (analytics, progress bars) needs rethinking
- _app.tsx and _document.tsx merge into the root layout.tsx; global providers become a client component that wraps children
- getServerSideProps and getStaticProps do not exist in app/ — data fetching moves into the components themselves, which usually means restructuring, not just relocating
- client components that read search params need a Suspense boundary, or the whole route falls back to dynamic rendering
- runtime CSS-in-JS (older styled-components and Emotion setups) needs a style registry and cannot run inside server components — Tailwind and CSS Modules migrate cleanly
Realistic expectations: a mid-sized production app — call it forty routes — is a few weeks of part-time migration, not a weekend. The mechanical renames are quick. The time goes into rethinking data flow on pages that leaned hard on getServerSideProps prop drilling, and into retesting everything that touched router events. Migrate your simplest static pages first to learn the model where the stakes are low, and your most interactive pages last.
And sometimes the right number of routes to migrate is zero. The Pages Router is not deprecated, not legacy in the pejorative sense, and not a liability. It is maintained, it is stable, and years of production knowledge are baked into the ecosystem around it. A stable app with no performance pain and nothing on the roadmap that needs streaming or server components has no business migrating. Migration for fashion is how you spend a month introducing regressions into an app your users were happy with.
There is also a class of app where the App Router's wins barely apply: heavily interactive, login-gated dashboards where nearly every component would be a client component anyway. If your tree would be ninety percent "use client", you have paid the full price of the new mental model for almost none of the bundle savings. Apps like that run fine on the Pages Router and should migrate last, if ever.
tip
A useful smell test for your own app: walk through your five most-visited pages and ask which components actually need state or event handlers. If the answer is "almost all of them", the App Router's main payoff mostly does not apply to you. If it is "a handful of islands in a sea of content", the App Router was designed for exactly your app.
Here is the rule, stated plainly. Choose the App Router if you are starting a new project — it is the default, it is where Next.js invests, and learning the old model first buys you nothing. Choose it for an existing app when the pain is real and named: content-heavy public pages dragging too much JavaScript, layouts refetching on every navigation, or slow queries blocking entire pages that streaming would fix. Stay on the Pages Router if your app is stable and nobody is complaining, if it is overwhelmingly client-side behind a login, or if the migration would displace roadmap work your users would actually notice. "Both are great" is a non-answer; for any specific app, one of those sentences is true and the others are not.
If you are starting something new this week, start in app/. Build one page as a server component, add one "use client" island, wire up a loading.tsx, and watch the model click faster from building than it ever will from reading. Where this decision sits in the bigger picture — which pieces make up a production stack and in what order to learn them — is the subject of the modern full-stack roadmap for 2026, where the router is one of a dozen choices that together decide what kind of software you can ship.
