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.
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.
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.
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.
