Migrating Pages Router to App Router Without a Freeze
The two routers run side by side in the same application, so the migration is route by route while your team keeps shipping. Here is the order that works and the three places it usually goes wrong.
The single most useful fact about this migration is that you do not have to
choose. pages/ and app/ run in the same application, in the same process,
against the same deployment. A request is matched by app/ first and falls
through to pages/ if nothing matches.
That means a feature freeze is not a requirement of the migration. It is a symptom of planning it as one large change instead of forty small ones.
The order that works
Migrate from the leaves inward, not from the root outward.
- A low-traffic, low-risk route first. Something like
/about. Not because it matters, but because it forces you to build the layout, the metadata helper and the data-fetching conventions you will use everywhere else, on a page nobody will notice if it breaks for an hour. - The rest of the static marketing routes. These convert almost mechanically and they are where the performance win is largest.
- Read-only dynamic routes. Product pages, article pages, listings. You
are converting
getStaticPropsandgetServerSidePropsintoasynccomponents here, which is the bulk of the mechanical work. - Authenticated and interactive routes. Dashboards, settings, checkout. These carry the real risk because they carry the mutations.
- The root layout, last. Moving
_app.tsxand_document.tsxearly forces every route into the new tree before any of them are ready.
Teams that invert this - starting with the shell because it feels like the foundation - are the ones that end up freezing.
What each Pages API becomes
| Pages Router | App Router |
|---|---|
getStaticProps | async component + fetch with a revalidate or tag |
getServerSideProps | async component + cache: 'no-store' |
getStaticPaths | generateStaticParams |
_app.tsx | app/layout.tsx |
_document.tsx | app/layout.tsx (the html and body tags) |
next/head | the metadata export or generateMetadata |
useRouter().query | params and searchParams props |
| API routes | Route Handlers, or Server Actions for mutations |
A route usually gets shorter. This:
// pages/products/[slug].tsx
export async function getStaticProps({ params }) {
const product = await getProduct(params.slug);
if (!product) return { notFound: true };
return { props: { product }, revalidate: 3600 };
}
export async function getStaticPaths() {
const products = await getProducts();
return {
paths: products.map((p) => ({ params: { slug: p.slug } })),
fallback: 'blocking',
};
}
export default function ProductPage({ product }) {
return <Product data={product} />;
}becomes this:
// app/products/[slug]/page.tsx
export async function generateStaticParams() {
const products = await getProducts();
return products.map((p) => ({ slug: p.slug }));
}
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return <Product data={product} />;
}Note await params. In current Next.js, params and searchParams are
promises. Code copied from a two-year-old tutorial will destructure them
directly and fail in a way that reads like a data problem.
What the two routers share, and what they do not
The coexistence that makes an incremental migration possible is not total, and the places it is not are where a migration stalls.
They share the request. One deployment, one domain, one next.config, and a
route resolves to whichever router owns it - with app/ taking precedence when
both define the same path. That last detail is the mechanism the whole strategy
rests on: you add the App Router version, it wins, and the old file stays in
place as a rollback that is one deletion away from being cleaned up or one
revert away from being live again.
They do not share layout. A pages/_app.tsx wraps only Pages routes, and an
app/layout.tsx wraps only App routes, so anything global - analytics, a theme
provider, a header - exists twice for the duration of the migration. Duplicate
it deliberately and delete one copy at the end. Trying to avoid the duplication
by importing one into the other is how a client provider ends up wrapping the
server tree.
They do not share client-side navigation. A link from a Pages route to an App route is a full document load, not a client transition. During a long migration that shows up as a page that feels slower than both of its endpoints, and it resolves itself when the pair lands on the same side.
The three places it goes wrong
Marking the shell use client to make the migration compile. Something in
the old layout uses a context provider, the build complains, and someone adds
'use client' at the top of the root layout. The application now compiles,
every route ships the entire tree to the browser, and the main benefit of the
migration is gone while the whole cost is still being paid. Push providers
down into a client component that wraps children, and keep the layout on the
server. This is the same boundary discipline as
where use client actually costs you.
Porting the data layer unchanged. getServerSideProps ran once per route,
so most codebases built a single fetch-everything function per page. In the
App Router, each component can fetch what it needs, and identical requests in
one render are deduplicated for you. Keeping the god-function works, and it
also keeps the whole route waiting on the slowest call - so nothing streams
and Suspense buys you nothing.
Leaving next/head in place. It is silently inert in app/. No error, no
warning - just a route with no title and no description, which nobody notices
until an SEO report arrives a month later. Grep for it before you ship, and
convert every instance to the metadata export.
Moving the auth check last. Authentication written for
getServerSideProps returns a redirect from a function that owns the whole
route. In the App Router the equivalent decision usually belongs in a layout or
in the route's own boundary, and the shape is different enough that teams defer
it - which means the routes behind it are the last to move and the migration
stops one step short of finishing on exactly the surfaces that matter most.
Move one authenticated route early, on purpose, to settle the pattern while the
stakes are low.
Proving it worked, route by route
Before you move a route, record what it does now: field LCP and INP, the rendered HTML, the title and canonical, the JSON-LD, the First Load JS. After you move it, compare. A migration without a before is a rewrite with extra steps.
The routes we have moved this way typically lose 30-50% of their JavaScript and gain nothing in risk, because at no point was more than one route in an unknown state.
When to stop
A migration that reaches ninety percent and parks there is a common and underrated outcome, and it is not automatically a failure. Two routers in one application is a supported configuration, not a broken state, and the last ten percent is usually a handful of routes with a genuine reason to be awkward: a custom server dependency, a library with no Server Component story, a page that is being replaced next quarter anyway.
The test for whether parking is acceptable is whether the remaining Pages routes cost you anything ongoing. If they are isolated, low-traffic and nobody edits them, finishing is tidiness rather than value. If they hold the shared layout hostage - so the App Router side cannot remove the duplicated provider - then they are costing you on every route, and that is the argument for finishing.
What is never acceptable is stopping without recording it. A migration with no written end state becomes, within one personnel change, a codebase where nobody knows which convention is current and both are copied forward.
If the codebase you are migrating is also one nobody wants to touch, that is a different problem stacked on top of this one - taking over an inherited Next.js codebase covers what to do first. And if the question is not how to migrate but whether the framework is right at all, that page exists too.
