The App Router isn't a version bump — it's a different mental model for routing, data fetching, and rendering. Here's what actually changes and how to migrate without a big-bang rewrite.
Teams that ask us about migrating from the Pages Router to the App Router usually frame it as a routing change — new folder conventions, new file names. That's the visible part, but it's the smallest part. The real migration is a shift in rendering model: Server Components as the default, data fetching that happens inside components instead of in separate page-level functions, and a nested layout system that changes how state and loading UI compose across a route tree. Treating it as a find-and-replace exercise is the most common way these migrations go sideways.
We've moved existing Next.js applications onto the App Router and started new projects on it directly. This is the practical version of what changes, what breaks, and how to sequence the work so a production site doesn't go dark for a sprint.
The Core Mental Model Shift
In the Pages Router, every file in pages/ is implicitly a Client Component that Next.js server-renders once and then hydrates. Data arrives through getServerSideProps, getStaticProps, or getInitialProps — special exported functions that run separately from your component and pass data in as props.
In the App Router, every component in app/ is a Server Component by default. It can fetch data directly inside its function body with a plain await, with no separate data-fetching function required. You opt into client-side behavior — state, effects, event handlers, browser APIs — explicitly with a "use client" directive at the top of a file. This is the single biggest adjustment for teams: the default has flipped from "everything is client-side" to "everything is server-side unless you say otherwise."
This matters for migration because it means you can't mechanically move a pages/ file into app/ and expect it to behave the same way. A component that calls useState or reads window will throw an error the moment it lands in app/ without a "use client" directive, and a component that used to fetch data via getServerSideProps needs that fetch moved inside the component itself, or into a dedicated data-fetching function it calls directly.
What Actually Changes File by File
- Routing structure:
pages/about.tsxbecomesapp/about/page.tsx. Every route is now a folder containing apage.tsx, rather than a single file named after the route. - Layouts:
_app.tsxand_document.tsxare replaced by a rootapp/layout.tsx, and nested folders can each have their ownlayout.tsxthat wraps only that section of the route tree — genuinely new capability, not just a rename. A dashboard section can have its own persistent sidebar layout without re-rendering on every navigation inside it. - Data fetching:
getServerSidePropsandgetStaticPropsare gone. Fetching happens withfetch()or a direct database call inside anasyncServer Component, with caching behavior controlled throughfetch's own options (cache: 'force-cache',next: { revalidate: 60 }) rather than a page-level export. - API routes:
pages/api/*.tsbecomes Route Handlers atapp/api/*/route.ts, exporting named functions per HTTP method (GET,POST) instead of one default export that branches onreq.method. - Loading and error states:
loading.tsxanderror.tsxfiles at any folder level give you automatic Suspense and error-boundary behavior for that route segment, without hand-wiring<Suspense>yourself everywhere.
Data Fetching Is Simpler in Shape, Different in Behavior
The biggest functional gotcha in migrations is caching. Pages Router data fetching was explicit about timing — getStaticProps at build time, getServerSideProps on every request. The App Router's fetch caches by default unless told not to, which means a request that used to hit your API on every page load might silently start serving a cached response after migration, and a team that doesn't know to look for next: { revalidate } or cache: 'no-store' can ship a page that appears to work in testing but serves stale data in production for far longer than intended.
The fix is to treat every migrated data fetch as a decision point rather than a copy-paste: does this data need to be fresh on every request, cached for a fixed window, or cached indefinitely until a specific event invalidates it? The App Router gives you all three options with more precision than the Pages Router did, but only if each fetch is deliberately configured rather than left on the default.
Client Components Don't Disappear — They Get Pushed to the Leaves
A common misconception is that migrating to the App Router means rewriting everything to avoid "use client". That's not the goal, and pursuing it too aggressively creates its own problems — components awkwardly split into a server wrapper and a client child just to avoid the directive, adding complexity without a real performance benefit for small, genuinely interactive pieces.
The practical target is pushing "use client" as far down the tree as it needs to go and no further: a page that's mostly static content with one interactive filter dropdown should have a Server Component for the page and layout, with only the dropdown itself marked as a Client Component. Trying to eliminate that one Client Component entirely usually isn't worth the contortion.
Third-Party Libraries Are the Most Common Blocker
Libraries built assuming a full client-rendered React tree — anything that reads window at import time, relies on React Context providers wrapping the whole app, or uses older patterns like componentWillMount lifecycle assumptions — often fail silently or loudly when a Server Component tries to render them. Context providers specifically need to live inside a Client Component boundary; you can't export a plain Context Provider from a Server Component and expect consumers deeper in the tree to see it.
Before migrating, it's worth auditing dependencies for App Router compatibility rather than discovering the incompatibility mid-migration. Most major libraries (state managers, UI kits, form libraries) have shipped App Router support by now, but niche or unmaintained packages are a real risk, and finding a replacement for one mid-migration is more disruptive than catching it during planning.
New Capability, Not Just Parity: Parallel and Intercepting Routes
Some App Router features have no Pages Router equivalent at all, which is worth knowing before migrating so a team doesn't just rebuild the old routing structure with new file names and miss the capability the change actually unlocks. Parallel routes let a layout render more than one page simultaneously into named slots — a dashboard that shows an analytics panel and a notifications feed side by side, each independently loaded and each with its own loading.tsx, without one blocking the other. Intercepting routes let a route "intercept" navigation to show it in a different context than its full page — the pattern behind a photo grid where clicking a photo opens it in a modal over the current page, but a direct link or refresh to that same photo's URL loads the full standalone page.
Neither pattern is achievable cleanly in the Pages Router without significant custom logic. Teams migrating primarily to fix a routing pain point often find that pain point was actually a parallel- or intercepting-routes problem, and the migration is a chance to remove workarounds that were built to approximate this behavior rather than just port them over unchanged.
SEO and Metadata Handling Changes Too
The Pages Router typically manages <title>, meta descriptions, and Open Graph tags through a <Head> component imported from next/head and placed inside each page. The App Router replaces this with a metadata export (a plain object) or a generateMetadata function, colocated with the route's page.tsx or layout.tsx, that Next.js reads and injects into the document head automatically — including support for dynamic metadata generated from the same data a page is already fetching, without duplicating that fetch.
This is a meaningful improvement for pages whose metadata depends on fetched data — a product page whose title and Open Graph image should reflect the actual product — since generateMetadata can await the same data source the page component does, and Next.js deduplicates identical fetch calls automatically rather than hitting the same endpoint twice. Sites with heavy reliance on next/head for custom scripts or third-party tags need to check each usage individually, since some patterns (injecting arbitrary scripts into the head) work differently under the new metadata API and may need to move to the dedicated next/script component instead.
A Migration Sequence That Doesn't Require a Big-Bang Rewrite
Next.js explicitly supports running the Pages Router and App Router side by side in the same project, which makes an incremental migration realistic rather than theoretical:
- Upgrade Next.js and confirm the existing Pages Router app still builds and runs unchanged. This isolates dependency and tooling issues from the routing migration itself.
- Move the root layout and static, low-risk pages first — marketing pages, static content — into
app/. These have no complex data fetching or client interactivity, so they surface basic App Router mechanics without much risk. - Migrate API routes next, since Route Handlers are a fairly mechanical translation from the old
pages/apihandlers, and get your backend logic working on the new convention before tackling complex pages. - Migrate data-heavy pages one at a time, deliberately setting cache behavior for each fetch rather than accepting Next.js's default, and testing that data freshness matches expectations.
- Leave the most complex, most interactive pages for last, once the team has hands-on experience with the Server/Client boundary from the easier pages already migrated.
- Remove the Pages Router only once every route has been migrated and verified — there's no requirement to delete it early, and keeping it around as a fallback during the transition reduces pressure to rush.
Where We'd Advise Against Migrating Immediately
If an application is stable, isn't planned for significant new feature work, and isn't suffering from a Pages Router limitation, migrating purely to be current isn't a strong justification on its own — it's real engineering time spent on a codebase that already does its job. The App Router earns its migration cost fastest on applications that are actively growing, where nested layouts, streaming, and Server Component data fetching will keep paying off on every new feature built afterward, not just once on migration day.
For new projects, the calculation is different and simpler: there's little reason to start a new Next.js build on the Pages Router today. The App Router is where Next.js's own investment and most third-party library support is heading, and starting there avoids a migration entirely.
The Practical Takeaway
The App Router migration is really two migrations bundled into one: a routing convention change that's mostly mechanical, and a rendering-model change that requires understanding Server Components, the client boundary, and fetch caching well enough to make deliberate decisions rather than copying old patterns into new file names. Done incrementally, page by page, with Pages Router and App Router running side by side during the transition, it's a manageable project. Done as a weekend rewrite without auditing dependencies or data-fetching behavior first, it's the kind of migration that produces a working demo and a production incident a week later.


