Your team is migrating a large Next.js app from the Pages Router to the App Router route by route, with both directories live in production. What degrades while the app is half-migrated, and how do you sequence the work to limit it?
answer
- the boundary is a full page load
- migrate islands, not easy files
- two shells, two dialects, one repo
- router.events has no successor
- stalling at 60% is the real risk
basics
~20 sEvery crossing between the two routers is a full page load that destroys client state, and the app shell is duplicated on both sides. Sequence by linked clusters of routes rather than by file difficulty, so the boundary falls where users rarely cross it.
solid answer
~50 sThe two routers share config, assets and middleware, but not a client-side router — so every navigation between them is a hard load that discards in-memory state and pays a full page's latency. On top of that you carry two shells: `_app`/`_document` on one side, `app/layout.tsx` on the other, with global CSS imported twice and providers initialising twice per session. The sequencing rule that follows is to migrate *clusters of routes that link to each other*, not the easiest files: pick a self-contained flow — marketing pages, or the whole settings area — and move it whole, so the boundary sits at an entry point users cross once. Move genuinely shared UI early enough that both sides can import it. And gate the work: an explicit deadline plus a rule that new routes are only written in `app/`, or the half-migrated state becomes permanent.
go deeper
Know that both directories can be live at once and that moving a route means creating the app/ file and deleting the pages/ file in the same change.
Explain the concrete duplications — two shells, global CSS twice, two router hook packages — and why importing useRouter from the wrong package breaks at runtime.
Show sequencing judgment: cluster routes by how users navigate so the boundary sits where crossings are rare, de-router shared components first, and verify each migrated route's rendering mode rather than just its markup.
Own the window itself — a no-new-routes-in-pages rule, named blockers for the routes pinned by third-party constraints, and an honest decision about whether the migration should proceed at all if the window cannot be closed.
## What actually degrades **Hard navigation at every boundary crossing.** The Pages Router and the App Router do not share a client-side router. A link from one to the other triggers a full document load: fresh JavaScript runtime, providers re-created, in-memory stores emptied, and the latency of a cold page rather than a soft transition. If a user's normal path bounces between the two — product page in `app/`, checkout still in `pages/` — they feel it on every step. **Client state does not survive.** Anything held in a Zustand/Redux/context store, a half-filled multi-step form, an open socket, an in-flight optimistic update — gone at the crossing. During the migration window, state that must survive has to move to cookies, storage, the URL, or the server. **Duplicate shells.** `pages/_app.tsx` and `pages/_document.tsx` keep serving the old routes while `app/layout.tsx` serves the new ones. Global CSS is imported in both places. Analytics, feature-flag clients and error reporters registered in both initialise once per side per session, which quietly doubles session counts and "app start" events unless you make those registrations idempotent. **Two dialects in one repo.** `getServerSideProps` here, async Server Components there; `useRouter` from `next/router` under `pages/` versus the `next/navigation` hooks under `app/`. That last one is the single most common migration bug: a shared component that imports `useRouter` from `next/router` throws when rendered inside an App Router tree, and a shared component importing from `next/navigation` fails under `pages/`. Shared components must therefore stop calling routing hooks and take what they need as props, or be split. **Third-party assumptions.** Libraries that hooked `router.events` — route-change progress bars, page-view analytics, scroll restoration helpers — have no drop-in equivalent, because `next/navigation` exposes no event stream. Those integrations get rewritten, not ported. Likewise, any library needing a client runtime injected by `_document` (classic CSS-in-JS style extraction is the usual culprit) has to be checked for App Router support before the routes that depend on it can move. ## How to sequence it **Cluster by link graph, not by difficulty.** The instinct is to migrate the simplest page first. The better heuristic is to look at where users navigate and cut the boundary where crossings are rare. Marketing/landing routes, a docs section, an account-settings area — each is a natural island. Moving a whole island means users cross the boundary once on entry and then stay on one side. **Move shared UI out of both trees early.** Anything imported by routes on both sides — the header, the design system, the auth-aware nav — should be free of router-specific imports before the migration widens. That usually means a small refactor: replace `useRouter()` calls inside shared components with props supplied by the route. **Do routes atomically.** Because two files that resolve to the same URL are a build error, each route move is inherently a create-and-delete in one change. Lean into it: one route (or one island) per PR, shipped, verified in production, then the next. **Reconcile middleware and auth first.** The root middleware file already covers both routers, so getting auth gating and redirects correct there — before the split widens — means the security posture does not have to be reasoned about twice. **Verify the migrated route's rendering, not just its output.** A faithfully translated route can look identical and still be wrong: the Pages Router version ran on every request, whereas the App Router version renders statically unless something in it forces dynamic rendering. Include "is this route still per-request when it needs to be" in the review checklist for every move. ## Keeping the window closed The real failure mode of incremental migration is not any single bug — it is stalling at 60%. Once the interesting routes are moved, the remaining ones are the ugly ones, and the duplicated shell becomes the new normal. Two mechanisms help: 1. A hard rule that **no new route is written in `pages/`**. Every new feature lands in `app/`, so the old tree can only shrink. 2. A named owner and a deadline for the residue, with the awkward routes (the ones blocked on a library or a CSS-in-JS runtime) identified as explicit, tracked blockers rather than "we'll get to it". If you cannot commit to closing the window, that is useful information about whether the migration should have started when it did.
- A shared header component calls useRouter to highlight the active nav item. What do you do with it during the migration?Stop calling a routing hook inside it. The `next/router` and `next/navigation` imports are mutually exclusive by router, so a component used from both trees cannot import either. Pass the current path in as a prop from each route's own side, or split the component and keep one thin router-aware wrapper per tree over one shared presentational component.
- Your analytics library registered page-view tracking through router.events in _app. What is the App Router replacement?There is no event stream to subscribe to. You rebuild it as a small client component rendered in the root layout that reads the current pathname and search params via the `next/navigation` hooks and fires a page view when they change. It is a rewrite, and it is worth confirming the vendor's own App Router integration exists before writing your own.
- How would you catch a migrated route that quietly stopped rendering per request?Two ways. At build time, the build output reports which routes are static and which are dynamic — diff that report against expectations as part of review. In production, add an assertion in the route itself or a synthetic check that a value which must change per request actually does, since a stale prerendered page looks perfectly healthy until someone notices the data is old.
saying these in an interview costs you the question
- Assumes client state survives navigation between the two routers
- Migrates the easiest files first without looking at the link graph
- Ports router.events code unchanged into an App Router component
- Leaves useRouter from next/router in components shared by both trees
- Treats indefinite coexistence as a stable end state