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.

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

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.

Related questions

Can both routers run at the same time?
Yes, and that is the whole point. They coexist in one application, so the migration goes route by route and nothing has to stop while it happens.
Which route should move first?
Something real but not critical. A marketing page teaches the team the model. The checkout teaches them under pressure.
What replaces getServerSideProps?
Nothing does, because the component itself becomes the place data is fetched. That is the change that takes longest to internalise and the one worth doing properly instead of mechanically.
Do we have to move everything?
Eventually, if you want one mental model in the codebase. But a stalled migration that left the hard routes behind still beats a rewrite that never shipped.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page