Skip to content

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.

5 min read

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.

  1. 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.
  2. The rest of the static marketing routes. These convert almost mechanically and they are where the performance win is largest.
  3. Read-only dynamic routes. Product pages, article pages, listings. You are converting getStaticProps and getServerSideProps into async components here, which is the bulk of the mechanical work.
  4. Authenticated and interactive routes. Dashboards, settings, checkout. These carry the real risk because they carry the mutations.
  5. The root layout, last. Moving _app.tsx and _document.tsx early 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 RouterApp Router
getStaticPropsasync component + fetch with a revalidate or tag
getServerSidePropsasync component + cache: 'no-store'
getStaticPathsgenerateStaticParams
_app.tsxapp/layout.tsx
_document.tsxapp/layout.tsx (the html and body tags)
next/headthe metadata export or generateMetadata
useRouter().queryparams and searchParams props
API routesRoute 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.

Back to all articles