The App Router has been the default for new Next.js applications for a while now, and most of the Pages Router applications we are asked to look at were written before that was true. The question we get is rarely "should we use the App Router" in the abstract. It is "we have 140 routes, a _app.tsx that has accumulated six providers, and a roadmap — what does moving actually cost us, and can we stop halfway?"
The answer to the last part is the important one. Both routers run in the same Next.js application. pages/ and app/ coexist, app/ wins when both define the same path, and requests that do not match app/ fall through to pages/. That makes this a route-by-route cutover you can pause indefinitely, not a rewrite. We have left clients sitting at 30% migrated for two quarters because the remaining routes had no reason to move. That is a legitimate end state, not an unfinished job.
Decide whether you have a reason
We do not sell rewrites, and this is a migration people start for the wrong reason more often than most. Reasons that hold up:
- Payload on content-heavy routes. Server Components ship no component JavaScript for the parts of the tree that stay on the server. If a marketing or catalogue route is currently shipping a markdown renderer, a date library, and a syntax highlighter to the browser to produce static output, the App Router deletes that download rather than deferring it. Check your analyzer output before you assume this applies — we wrote about reading it in cutting a React bundle.
- Waterfalls caused by the data-fetching model.
getServerSidePropsruns once per page, at the top, so a nested component that needs its own data either gets it threaded down through props or fetches on the client after hydration. App Router lets the component that needs the data fetch it, and the requests go out in parallel with rendering. - Layouts that genuinely nest. A dashboard with a shell, a section sidebar, and per-tab content is three levels of layout being simulated with
getLayoutand manual state preservation. Nested layouts do this natively and do not re-render on navigation. - Access to the current API surface. Partial prerendering, streaming metadata, and most of what ships in Next.js now assumes
app/. If you expect to be on this framework for another three years, the Pages Router is a slowly narrowing path.
Reasons that do not hold up: the release notes, a recruiter's opinion, or an internal sense that the codebase is dated. A Pages Router app with good Core Web Vitals and a team that ships is not a problem. Measure it first.
Pick the first route deliberately
The first migrated route is a spike, and it should be chosen to answer questions cheaply, not to deliver value. We look for a route that is low-traffic, has one data source, does not sit behind the most complex authorization rule in the app, and is owned by someone who will be around next month. A settings sub-page or an internal admin view is usually ideal.
What that route is really proving out is the infrastructure underneath it: app/layout.tsx with your providers, the font and CSS setup duplicated correctly, analytics still firing on client-side navigations, your auth session readable in a Server Component, and your error and monitoring pipeline receiving useful stack traces from server-rendered components. All of that is one-time work, and all of it is easier to debug on a page nobody is looking at.
Budget real time for the root layout. It replaces _app.tsx and _document.tsx, and the providers in it — theme, analytics, query client, feature flags — are client components that will wrap your entire tree. Wrap narrowly: a provider placed at the root turns every descendant into a client component's child, which does not by itself make them client components, but a provider that reads from context and renders children needs those children passed as props, not imported. Getting that wrong is the most common reason a team reports that "the App Router didn't reduce our bundle".
The rewrites that are mechanical, and the ones that are not
Mechanical, in the sense that the transformation is predictable even if it is not free:
getServerSidePropsbecomesawaitin the component, or a small server-only function called from it.getStaticPropsplusrevalidatebecomes a fetch with arevalidateoption or a route-levelexport const revalidate.next/headbecomes the exportedmetadataobject orgenerateMetadata.useRouterfromnext/routersplits intouseRouter,usePathname, anduseSearchParamsfromnext/navigation, with a different shape —router.queryno longer exists, anduseSearchParamsin a statically rendered route needs a Suspense boundary above it.- API routes under
pages/apikeep working. Move them last or never; there is little to gain from converting a working handler to a Route Handler, and the request and response objects differ enough to make it a real change.
Not mechanical, and worth scoping separately:
Caching semantics. This is where teams lose time. Fetch caching, the router cache, and the distinction between static and dynamic rendering behave differently from getStaticProps, and the defaults have changed across Next.js versions. Read the caching documentation for the exact version you are on rather than a blog post from two years ago, and add one test per migrated route that asserts whether it renders statically or dynamically. A route that silently became dynamic is a performance regression that no lint rule will catch.
Shared client state across the boundary. While both routers are live, a user can navigate from an app/ route to a pages/ route. That is a full document load, not a client transition. Any state held in memory — a cart, a wizard's progress, an open websocket — is lost at that crossing. Two consequences: migrate routes that share state as a group rather than individually, and make sure anything that must survive lives in a cookie, URL, or server session before you split the group.
Third-party libraries that assume the Pages Router. Anything reaching into _app, or a client-only library with no 'use client' story, will need a wrapper or a replacement. Inventory these before you plan the order; one unmaintained dependency in the critical path can be the deciding constraint on the whole sequence.
Sequence, measure, merge
After the spike, group the remaining routes by shared layout and shared state, and order the groups by the payoff you argued for at the start. Each group is its own pull request, merged to main behind whatever flagging you already use. Nothing lives on a long-running branch; a six-week migration branch against an active codebase costs more in conflict resolution than the migration itself.
Capture field data per route before and after. If the argument for moving was bundle size or LCP, you should be able to show the numbers on the routes you moved — and if a group comes back flat, that is information about the remaining groups. Stop and re-plan rather than finishing on momentum.
One honest caveat about the end of the project. The last 15% of routes are usually the ones with the awkward dependency, the bespoke authorization, or the owner who left. They are also usually the lowest-traffic. Running two routers indefinitely costs you a little duplicated layout code and one class of bug at the navigation boundary. Rewriting those routes to finish a migration nobody is measuring costs more. Decide that on the evidence, the same as everything else.