May 7, 2026 · 12 min read · Cadence Editorial

How to migrate from Pages to App Router safely

pages to app router migration — How to migrate from Pages to App Router safely
Photo by [Bibek ghosh](https://www.pexels.com/@bibekghosh) on [Pexels](https://www.pexels.com/photo/code-on-computer-screen-14553730/)

How to migrate from Pages to App Router safely

A safe pages to app router migration is incremental, route-by-route, with both routers running in the same Next.js app for weeks (sometimes months). You start with a single low-traffic route in /app, prove the data fetching and auth patterns hold, then move the rest one segment at a time while /pages keeps shipping traffic.

The teams that get burned try to flip the whole app in one branch. The teams that ship safely treat app/ as a parallel deployment target inside the same repo, with a clear order: layout shell first, leaf routes next, dynamic segments after that, API routes last.

Why this matters in 2026

Next.js 16 (April 2026) deprecated several Pages-only behaviors, and Vercel's build pipeline now flags Pages-only apps as "legacy" in the dashboard. The App Router is the default for every create-next-app and the only one that supports Server Actions, Partial Prerendering, and the streaming Suspense boundaries React 19 leans on.

The cost of staying on Pages is no longer zero. Your team ships more JavaScript than it has to, your auth code is written against Node.js request objects with no future, and every hire who joined a Next.js shop in the last 18 months has only written App Router code. App Router apps now make up over 78% of new Next.js deployments on Vercel. The longer you wait, the more your repo looks like a 2022 codebase.

The default approach (and why it's flawed)

Most teams reach for the "big bang" migration: a long-lived branch, every route rewritten at once. It feels clean. It is almost always wrong.

The failure mode shows up in week three. Your branch has 400 file changes, your PR can't be reviewed, and every merge from main introduces a new conflict in _app.tsx or a layout file you already rewrote. By the time you're ready to ship, your team built two months of new features on top of Pages, and you have to redo half the migration to catch up.

The other common failure: someone runs the codemod, sees green, and merges. Codemods handle imports and a handful of patterns. They do not catch the request-object differences, the useRouter import split between next/router and next/navigation, or the cookies/headers pattern shift. You ship, your error monitoring lights up at 2 AM, and you spend three days reverting.

The better approach: step-by-step

Here is the order that works on real production migrations. It assumes you already updated to Next.js 16+ and Node 20+ before touching app/.

1. Stand up app/ as an empty parallel router

Create the directory. Add a root layout. Do not move any pages yet.

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

What can go wrong: the root layout must define <html> and <body>. If you forget either, dev server boots fine and prod builds fail with a cryptic hydration error. Also: any global stylesheet imports you had in pages/_app.tsx need to be re-imported here, otherwise styles vanish on app/ routes only.

The reach-for tool here is just the Next.js CLI. Don't run @next/codemod yet. You want to feel the surface before you automate against it.

2. Move one leaf route as a proof of concept

Pick a route with no auth, no data fetching, and no shared layout state. A static /about page or a /changelog is ideal. Move it from pages/about.tsx to app/about/page.tsx.

// app/about/page.tsx
export const metadata = {
  title: 'About',
}

export default function AboutPage() {
  return <main>About us</main>
}

What can go wrong: if you also have pages/about.tsx, Next.js will throw a build error about conflicting routes. The app/ directory wins, but the build will refuse to start until you delete the pages/ version. Delete the old file in the same commit.

3. Migrate _app.tsx providers into a client layout

Your pages/_app.tsx probably has React Context providers (theme, auth, query client). These cannot live directly in app/layout.tsx because that file is a Server Component. You need a sibling client component.

// app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider attribute="class">{children}</ThemeProvider>
    </QueryClientProvider>
  )
}
// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

What can go wrong: keep _app.tsx and _document.tsx alive while pages/ still has routes. The app/ layout only applies to app/ routes, and styles imported in app/layout.tsx will not apply to anything still living under pages/. Two providers, two style imports, until the migration is done.

4. Convert getServerSideProps to async Server Components

This is the meat of the migration. Pick a route with getServerSideProps, move it, and validate the auth and data flow before you move the next one.

Before:

// pages/dashboard.tsx
import { getAuth } from '@/lib/auth'

export async function getServerSideProps({ req }) {
  const { userId } = getAuth(req)
  if (!userId) return { redirect: { destination: '/login', permanent: false } }
  const projects = await fetch(`https://api.example.com/projects/${userId}`).then(r => r.json())
  return { props: { projects } }
}

export default function Dashboard({ projects }) {
  return <ProjectList projects={projects} />
}

After:

// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { ProjectList } from './project-list'

export default async function DashboardPage() {
  const session = (await cookies()).get('session')?.value
  if (!session) redirect('/login')
  const userId = await verifySession(session)
  const projects = await fetch(`https://api.example.com/projects/${userId}`, {
    cache: 'no-store',
  }).then(r => r.json())
  return <ProjectList projects={projects} />
}
// app/dashboard/project-list.tsx
'use client'
export function ProjectList({ projects }) {
  // interactive bits live here
}

What can go wrong: cookies() and headers() became async in Next.js 15. If you forget the await, TypeScript yells but the runtime returns a Promise that quietly becomes undefined after .get(). Also, cache: 'no-store' is the explicit way to opt out of caching; without it, your fetch may be deduped and cached across requests in ways getServerSideProps never was.

5. Convert getStaticProps and getStaticPaths

Static segments map cleanly. getStaticProps becomes the default fetch behavior (cached until invalidated), and getStaticPaths becomes generateStaticParams.

// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map((post) => ({ slug: post.slug }))
}

export const revalidate = 3600 // ISR: regenerate at most once per hour

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())
  return <article>{post.body}</article>
}

What can go wrong: in Next.js 15+ the params prop is a Promise. Forgetting to await it gives you [object Promise] in URLs and cryptic React errors. Also, dynamicParams = false if you want a hard 404 on slugs not returned by generateStaticParams. Default is true, which lazily generates and caches.

6. Convert API Routes to Route Handlers

API routes in pages/api/* keep working. You can migrate them last, in parallel, or never (they are not deprecated). When you do migrate, the conversion is mostly mechanical, and the same REST endpoint design rules apply.

Before:

// pages/api/webhooks/stripe.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end()
  const sig = req.headers['stripe-signature']
  // ...
  res.status(200).json({ received: true })
}

After:

// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')
  const body = await req.text() // critical: Stripe needs the raw body
  // ...
  return NextResponse.json({ received: true })
}

What can go wrong: webhook handlers that need the raw body (Stripe, GitHub, Slack) were tricky in pages/api because of the body parser. In Route Handlers, req.text() gives you the raw string by default. No more export const config = { api: { bodyParser: false } }. Also, the file must be named route.ts, not index.ts or the folder name. This trips up everyone once.

7. Update middleware last

middleware.ts at the project root works for both routers. But its semantics shifted: in App Router contexts, middleware runs before any caching layer, so what you do here directly affects ISR and Partial Prerendering. Audit your middleware for anything that touches cookies or headers in a way that would force a route to be dynamic. If you're using middleware for API rate limiting, the bucket key logic stays the same; the response shape is what changes.

Common pitfalls

The patterns below look correct in dev and break in production. We've seen each of these on real engagements.

  • Forgotten 'use client' directive on a hook-using component. Symptom: build fails with "useState only works in client components." The directive must be the first non-comment line in the file, before imports. One missing directive cascades into ten more in the import tree.
  • Suspense boundaries swallowing real errors. The App Router streams responses, which means a thrown error inside a Suspense boundary renders the fallback forever instead of bubbling. Wrap every Suspense in an error.tsx sibling. Symptom in production: spinner that never resolves, no error in your monitoring.
  • cookies() or headers() called from a route that should be static. Calling either flips the route to dynamic for the entire request. If you set a cookie in a layout, every page under it goes dynamic. Symptom: build output shows λ (server) instead of (static) on routes you expected to be cached. Use dynamic = 'force-static' to get a build-time error if anything in the route violates static rendering.
  • useRouter import collisions. next/router (Pages) and next/navigation (App) export hooks with the same name and different signatures. A shared component that calls useRouter() cannot live in both worlds. The escape hatch is next/compat/router, which works in both, until you finish the migration and switch to next/navigation. Without it, you maintain two copies of the component.
  • Mixed-router navigation triggers full page loads. A <Link> from /pages/dashboard to /app/billing does a hard navigation, not a client-side transition. Prefetching does not cross the boundary. Plan your migration order so that high-traffic navigation paths land entirely in app/ before users notice.
  • force-dynamic used as a panic button. It works. It also opts you out of every performance benefit Next.js offers. Use it deliberately for routes that genuinely cannot be cached (a real-time dashboard reading from a per-user database). Don't sprinkle it on every route to silence build warnings; you're papering over a missed await cookies() somewhere.

When you can skip this entirely

Honestly: if your app is on Pages Router, has no scaling problems, no hiring problems, and no plans to adopt Server Actions or Partial Prerendering, you do not need to migrate this quarter. Pages Router is supported, the team patches security issues, and Vercel's build pipeline still treats it as a first-class citizen.

The signals that say "migrate now":

  • You are shipping more than a dozen new routes per quarter and feel the friction of _app.tsx and per-page getLayout.
  • Your auth provider (Clerk, Auth.js, Supabase Auth) has shipped App Router primitives that you cannot use.
  • Hiring loops keep surfacing candidates who haven't written Pages Router in two years and will need ramp time on your codebase.
  • You want Server Actions (form mutations without an API route) and Partial Prerendering (static shells with dynamic holes), and rebuilding around them on Pages is not viable.

If none of those apply, leave it alone. The best practices ROI curve has a flat section.

What this costs in engineer-time

A pages to app router migration on a real production app is not a weekend. Here's the honest sizing for an app with around 40 routes, a couple of providers, auth, ISR pages, and a handful of API routes that need to become Route Handlers:

PhaseWorkEngineer time
Setup + parallel routingRoot layout, providers, first leaf route0.5 weeks
Static pagesMarketing routes, docs, simple pages0.5 weeks
Dynamic + auth pagesDashboard, account, billing1.5 weeks
API → Route HandlersWebhooks, internal endpoints0.5 weeks
Cleanup + cutoverRemove _app.tsx, _document.tsx, audit middleware0.5 weeks
TotalOne senior engineer3.5 weeks

At Cadence's senior tier ($1,500/week), that's roughly $5,250 of engineering for a clean migration with tests staying green and no late-night reverts. A junior or mid handling this alone usually balloons to 6 to 8 weeks because the App Router's caching and streaming model takes time to internalize. This is the kind of focused, scoped engagement where booking a senior for the duration outperforms hiring full-time.

Every engineer on Cadence is AI-native by default (Cursor, Claude Code, and Copilot are baseline tools, vetted in a voice interview before they unlock bookings), which matters for migrations like this because most of the boilerplate transformation is exactly what coding assistants are good at. The senior is making the architecture calls, the AI is doing the typing, and the server actions playbook for Next.js 15 (which carries forward to 16) is the natural follow-up once the migration lands.

If you want a fast read on whether your current Next.js codebase is ready for this migration or needs a refactor first, run it through the Cadence stack audit tool for an honest grade on what to migrate, what to delete, and what to leave alone.

Ready to ship the migration without burning a sprint on it? Book a senior Next.js engineer on Cadence with a 48-hour free trial, weekly billing, and no notice period. We'll have someone on the codebase by Wednesday.

FAQ

How long does a pages to app router migration take?

For a typical 30 to 50 route production app, plan on 3 to 4 weeks of focused senior engineering work. Apps with heavy custom middleware, complex auth, or deep i18n usage can stretch to 6 weeks. The migration is incremental, so you ship continuously rather than holding a long-lived branch.

Can I keep some routes on Pages Router permanently?

Yes. Both routers run in the same app indefinitely. Many teams keep pages/api/* for webhooks (especially if they predate Route Handlers) and migrate everything else. The only constraint is that a single URL path lives in one router or the other, not both.

Do I have to rewrite all my data fetching at once?

No. Migrate one route at a time. Each app/ page is independent, fetches its own data, and has no dependency on the migration state of other routes. Keep getServerSideProps in the unmigrated pages/ files; they'll keep working.

What about next-i18next and other Pages-only libraries?

This is the hardest part. Several popular libraries (older versions of next-i18next, some auth SDKs, certain analytics packages) only work in pages/. Check each dependency for App Router support before you start. If a library is Pages-only and critical, either upgrade to a version that supports both, swap to an alternative (next-intl is the common replacement for next-i18next), or delay that route's migration until the library catches up. For the deeper architecture decisions involved here, the tech stack selection playbook walks through how to evaluate a swap.

Is the App Router actually faster?

For most apps, yes, but the gain comes from sending less JavaScript to the client (Server Components ship zero JS) rather than from faster server rendering. If your app is mostly client-heavy interactive UI, the speed difference is modest. If it's mostly content with islands of interactivity, the bundle reduction is dramatic, often 40% to 60% smaller initial JS payloads.

Should I run the official codemod first?

Run it on a throwaway branch to see the diff. Use the diff as a checklist, not as the final commit. The codemod handles import renames and a few file-level patterns; it does not handle the request-object semantics, the cookies and headers async API, the Suspense streaming boundaries, or the Route Handler migrations. Treat it as a starting point, not the migration itself.

All posts