open atlas
↑ Back to track
Next.js, zero to senior NEXT · 08 · 03

pages/ to app/ migration: coexistence, the translation map, and a quarters-not-sprints timeline

Both routers run side by side, so migration is route-by-route: getServerSideProps becomes an awaited fetch in an RSC, _app becomes the root layout, and router.events has no routeChangeStart replacement. Leaf pages move first; a 100-page app takes quarters, not sprints.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A 117-page pages-router SaaS announces a big-bang rewrite: branch app-router-v2, six weeks, full team. Month four: the branch is 400 commits behind main, the global NProgress bar is dead because router.events does not exist in the app router, styled-components flashes unstyled markup until someone discovers the server-insertion registry, and QA finds checkout sessions silently expiring — the sliding-refresh lived in getServerSideProps, and the port calls cookies().set inside a Server Component, which throws. Month five: the branch is abandoned. Cost: a quarter of roadmap, zero shipped value. The second attempt inverts everything — pages/ and app/ run side by side in one build, routes move one at a time starting with leaves, every PR ships to production behind the route boundary. Steady state lands at about two routes per engineer-week; the full migration completes in three quarters with no release freeze. Same codebase, same team. The difference was refusing to fight the framework’s actual migration design.

Coexistence is the migration design

Both routers compile into one build, and one rule keeps them honest: a URL belongs to exactly one of them. If pages/pricing.tsx and app/pricing/page.tsx both exist, the build fails with a conflict error — that error is your safety net, a machine guarantee that every route lives in exactly one world and a half-migrated route cannot silently shadow its old self. Navigation across the boundary works through Link in both directions, but it is a full document load, not a client transition — the two routers do not share a client runtime. That has a cost worth stating plainly: during migration, paths that cross the boundary pay double framework overhead (both runtimes ship on the journey), and per-page state does not survive the crossing. Middleware, by contrast, applies uniformly to both routers — it is the one layer that migrates for free.

The translation map

Most of the mechanical work is a table, and most of the incidents hide in its corners. getServerSideProps becomes an async Server Component that awaits its data inline — but note the trap: gSSP was implicitly per-request, while in the app router static is the default. A naive port that just calls fetch without cache: 'no-store', a revalidate option, or any dynamic API usage produces a page that is prerendered once and frozen — see the failure mode quiz below. getStaticProps plus revalidate becomes fetch with next: { revalidate: 60 } (or a segment-level revalidate export); getStaticPaths becomes generateStaticParams. _app and _document collapse into the root layout, with providers becoming a client component wrapping children; next/head becomes the metadata export or generateMetadata. API routes move from pages/api to route handlers in app/**/route.ts.

Then the corner with no table entry: router.events is gone and has no equivalent. The app router exposes usePathname and useSearchParams — reactive values that update after a navigation commits. There is no routeChangeStart. Every global progress bar wired to it — NProgress in _app is in half the production apps in existence — silently stops working: no error, no bar. The realistic options are wrapping Link (and programmatic pushes) to emit your own start signal, adopting a community library that does that patching, or accepting the framework’s own model — per-segment loading.tsx, which is genuinely better UX but is per-route, not global.

Quiz

After the shell moves to the app router, the global NProgress bar wired to router.events routeChangeStart never appears again — no errors anywhere. Why, and what is the realistic replacement?

The data-fetching mental flip

If you port the fetching pattern faithfully but miss the mental model shift, you will write app-router code with pages-router accents for years — and wonder why components are harder to compose than they should be.

The translation table is mechanical; this part is conceptual, and teams that skip it write app-router code with pages-router accents for years. In pages/, data fetching was per-page: one blessed function at the top, a props pyramid drilling everything down. In app/, fetching is per-component: any Server Component awaits exactly the data it renders. What makes that affordable is request memoization — within a single render pass, identical fetch calls (same URL, same options) are deduplicated automatically, so the layout, the page, and a deep card component can each await getUser() and produce one network call. React’s cache() extends the same guarantee to non-fetch functions like direct database queries. The consequences: page-props types and drilling disappear, components become self-contained about their data, and the new discipline is watching for server-side waterfalls — sequential awaits that should be parallel. Where two independent fetches happen in one component, start them together with Promise.all; where they happen in parent and child, that is usually fine — memoization plus streaming covers it.

Middleware moves unchanged — same file, same matcher, both routers. The trap is the cookie write path. In getServerSideProps you could refresh a session cookie on every request by setting headers on the response. In the app router, cookies() is readable everywhere server-side, but mutation is only allowed in Server Actions and Route Handlers — an RSC render cannot set cookies, because by the time a component renders, the response may already be streaming, and the render may be cached and replayed for other users. The sliding-expiration pattern from the hook must relocate to middleware, which runs per-request before rendering and can set cookies on the response it forwards. This is not a syntax change; it is a relocation of responsibility, and it is the single most common auth regression in real migrations.

Quiz

A session sliding-refresh that re-set the cookie on every request lived in getServerSideProps. The port calls cookies().set inside the page Server Component and throws at runtime. Where can the refresh live now?

CSS-in-JS pays a styling tax

Runtime CSS-in-JS — styled-components, Emotion — generates styles during render, which collides with streaming server rendering. The supported workaround is a style registry: a client component using useServerInsertedHTML to flush collected styles into the stream ahead of the HTML that needs them. It works, but read the fine print: these libraries run only in client components, so a styled leaf can never become a Server Component — your styling library now dictates your server/client split. Teams choose between shipping the registry as acknowledged scaffolding debt, migrating hot paths to CSS Modules or Tailwind before moving them, or adopting a zero-runtime library (vanilla-extract and friends) that compiles away the problem. Deciding this before the first route moves is the difference between a styling strategy and a styling archaeology.

Sequencing and the honest timeline

Order is strategy. Leaf pages first — settings, legal, content pages with shallow layout coupling — because they build the team’s pattern vocabulary (registry, metadata, fetch options) where mistakes are cheap. Shared-layout clusters move together: until a cluster fully migrates, its shell exists twice — once in _app, once in a layout — and every shell change is double maintenance, so a half-moved cluster is the worst steady state. Resolve early the URL both routers fight over — typically an optional catch-all in pages/ overlapping a static route in app/ — using the conflict build error as the referee. Revenue-critical paths (checkout, auth flows) go last, when the team’s pattern knowledge peaks. The honest timeline for ~100 pages: the first route takes two to three weeks — it carries the root layout, the style registry, the auth relocation, and the team’s learning curve. Steady state settles near one to two routes per engineer-week with feature work continuing in parallel. The tail is slow again. Total: two to three quarters — and the burn-down metric that keeps it honest is one number, routes remaining in pages/, reported weekly until it reads zero and pages/ is deleted.

Recall before you leave
  1. 01
    Walk the translation map, including the two entries that are traps rather than renames.
  2. 02
    State the sequencing strategy and the honest timeline for a ~100-page app, including why cookie writes and CSS-in-JS are decided early.
Recap

The failed big-bang branch and the successful three-quarter migration were the same codebase — the difference was using the framework’s actual design: pages/ and app/ compile into one build, a URL belongs to exactly one router with a conflict build error as referee, navigation crosses the boundary as a full document load, and middleware spans both for free. The translation map does the mechanical part — getServerSideProps becomes an awaited fetch in an async Server Component with the static-by-default trap waiting for naive ports, getStaticProps maps to revalidate options, getStaticPaths to generateStaticParams, _app and _document to the root layout, next/head to metadata — and hides two real breakages: router.events is gone with no equivalent, killing every routeChangeStart-driven progress bar silently, and cookie mutation is forbidden during RSC render, forcing session sliding-refresh out of data fetching and into middleware or Server Actions. The conceptual flip is per-component data fetching: request memoization dedupes identical fetches within a render pass and cache() covers non-fetch functions, so props pyramids dissolve — the new discipline is hunting server-side waterfalls with Promise.all. Runtime CSS-in-JS pays a tax: a useServerInsertedHTML registry, plus the structural constraint that styled components stay client components — decide registry, pre-migration to CSS Modules, or zero-runtime before the first route. Sequencing is the strategy: leaves first for cheap learning, shared-layout clusters atomically to avoid dual shells, contested URLs resolved early, checkout last. Honest numbers: first route two to three weeks, steady state one to two routes per engineer-week, two to three quarters for ~100 pages — tracked by a single burn-down metric, routes remaining in pages/, until it hits zero and pages/ is deleted. Now when a team proposes a big-bang rewrite of pages/ in six weeks, you know the exact cost: a quarter of roadmap, a branch 400 commits behind main, and a migration designed to be incremental by the framework itself.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.