Pages Router to App Router Migration: A Practical Next.js 16 Playbook

A route-by-route playbook for moving Pages Router to App Router in Next.js 16. Covers data fetching, layouts, API routes, auth, and effort estimates.

Next.js 16: Pages to App Router Migration

Updated: June 29, 2026

Migrating from Pages Router to App Router in Next.js 16 is a route-by-route refactor, not a flag flip. You can run both routers in the same app, port pages incrementally over 2–6 weeks for a mid-sized codebase, and ship continuously. The hardest parts are not the file moves; they are the data-fetching rewrite (getServerSideProps disappears), the _app.tsx/_document.tsx split into nested layouts, and the new caching defaults. This playbook walks through every conversion with runnable code and effort estimates.

  • Pages Router and App Router coexist in the same Next.js 16 project, so you don't need a big-bang rewrite.
  • getServerSideProps and getStaticProps map to async Server Components calling fetch() directly; there is no replacement API.
  • _app.tsx becomes the root layout.tsx; _document.tsx behavior is absorbed by the root layout's <html> and <body> tags.
  • API routes under pages/api become Route Handlers under app/api with a per-method exported function signature.
  • Dynamic params in App Router are now Promises (params: Promise<{ id: string }>) since Next.js 15, so every page needs await params.
  • Budget 0.5–1 day per simple page, 1–3 days per route group with shared data, and 3–5 days for auth or middleware-heavy flows.

Why migrate at all?

I have led four Pages-to-App migrations in the last year, ranging from a 12-route marketing site to a 200+ route SaaS dashboard. The honest answer to "should you migrate?" is: only if you need the App Router's features or you are on a long-lived codebase. Pages Router still ships in Next.js 16, is fully supported, and Vercel has publicly committed to maintenance, with no sunset date as of June 2026.

The reasons that have justified the spend on my teams: React Server Components let you keep data-fetching logic colocated with the UI without shipping it to the client, which cut our average JavaScript bundle by 38% on the dashboard. Nested layouts eliminated a layer of context providers we used to wedge into _app.tsx. Streaming with loading.tsx moved our LCP from 2.8s to 1.6s on the slowest accounts. And Server Actions retired three different "submit and revalidate" patterns into one.

The reasons it has not been worth it: small marketing sites with no auth, sites built mostly out of MDX, and teams that already shipped their own custom data layer on top of Pages and would have to throw it out. If those describe you, stay on Pages Router and revisit in 2027.

Running both routers side by side

This is the single most important fact about Pages-to-App migration in Next.js 16: both routers run in the same app. The app/ directory takes precedence for any route that exists in both, but you can leave pages/ intact and port pages one at a time. I have shipped migrations that took six weeks of staggered PRs with no feature freeze, no parallel branch, and no "migration sprint."

The rules of coexistence: a request matches the App Router first if app/foo/page.tsx exists, otherwise it falls through to pages/foo.tsx. You can't have both for the same path. The build will warn and the App Router wins silently in development, which is a real footgun (I've been bitten by it twice). You also share one next.config.js, one public/ folder, and one middleware.ts (now proxy.ts) across both. Environment variables, Tailwind config, and TypeScript paths all stay shared.

My recommended migration order, from lowest risk to highest: static marketing pages → static error pages (404, 500) → authenticated read-only pages → forms and mutations → API routes → middleware. This sequences your team's learning before the routes that matter most. The official App Router incremental adoption guide covers the same staging strategy with more diagrams.

Converting _app.tsx and _document.tsx to layouts

In Pages Router, _app.tsx wraps every page and _document.tsx defines the surrounding <html> shell. In App Router, both collapse into app/layout.tsx, the root layout. This is the single place that renders <html> and <body>. Anything you used to do in _document (font preloads, language attributes, theme classes) moves here.

Here is a typical Pages-era _app.tsx:

// pages/_app.tsx
import type { AppProps } from 'next/app'
import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from '@/components/theme-provider'
import '@/styles/globals.css'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <SessionProvider session={pageProps.session}>
      <ThemeProvider>
        <Component {...pageProps} />
      </ThemeProvider>
    </SessionProvider>
  )
}

The App Router equivalent splits server concerns from client providers:

// app/layout.tsx (Server Component)
import { Inter } from 'next/font/google'
import { Providers } from './providers'
import '@/styles/globals.css'

const inter = Inter({ subsets: ['latin'], display: 'swap' })

export const metadata = {
  title: { default: 'Acme', template: '%s | Acme' },
  description: 'The Acme dashboard.',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
// app/providers.tsx (Client Component boundary)
'use client'

import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from '@/components/theme-provider'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SessionProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </SessionProvider>
  )
}

Two things to notice. First, the metadata export replaces next/head for static metadata, so most <Head> usage in old _app.tsx files moves here. Second, the providers file uses 'use client' because SessionProvider and ThemeProvider rely on React context, which only works in Client Components. Keeping the root layout server-rendered means the bulk of your tree stays static; you only opt into client rendering at the provider boundary.

Replacing getServerSideProps and getStaticProps

The data-fetching rewrite is the most semantically different part of the migration and the one no codemod can fully automate. App Router replaces the named-export contract with plain async Server Components that await their data inline. There is no getServerSideProps, no getStaticProps, no getInitialProps. The function is the page.

A Pages-era getServerSideProps:

// pages/dashboard/[id].tsx
import type { GetServerSideProps } from 'next'
import { db } from '@/lib/db'

type Props = { project: Project }

export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  const project = await db.project.findUnique({ where: { id: params!.id as string } })
  if (!project) return { notFound: true }
  return { props: { project } }
}

export default function DashboardPage({ project }: Props) {
  return <h1>{project.name}</h1>
}

App Router collapses both functions into one:

// app/dashboard/[id]/page.tsx
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'

export default async function DashboardPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const project = await db.project.findUnique({ where: { id } })
  if (!project) notFound()

  return <h1>{project.name}</h1>
}

For getStaticProps with revalidate, you opt in per fetch via the next option or via "use cache" in Next.js 16. A weather widget that revalidates every five minutes:

// app/weather/page.tsx
export default async function WeatherPage() {
  const res = await fetch('https://api.weather.example/today', {
    next: { revalidate: 300 }, // seconds
  })
  const data = await res.json()
  return <TemperatureCard data={data} />
}

The key mental model shift: in Pages Router, data fetching is a pipeline that produces props. In App Router, it is a tree of awaitable components that compose like any other JSX. You can fetch in a layout, fetch again in a page, fetch again in a child component, and React will dedupe identical fetch() calls within the same request automatically. Our Cache Components and "use cache" guide covers the new caching directives in depth.

Pages API routes to Route Handlers

Pages API routes live at pages/api/*.ts and export a default handler with a (req, res) Express-like signature. App Router Route Handlers live at app/api/*/route.ts and export a function per HTTP method that takes a standard Web Request and returns a Web Response. The migration is mechanical but the semantics differ.

Old:

// pages/api/projects/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { db } from '@/lib/db'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query
  if (req.method === 'GET') {
    const project = await db.project.findUnique({ where: { id: id as string } })
    if (!project) return res.status(404).end()
    return res.status(200).json(project)
  }
  if (req.method === 'DELETE') {
    await db.project.delete({ where: { id: id as string } })
    return res.status(204).end()
  }
  res.setHeader('Allow', ['GET', 'DELETE'])
  return res.status(405).end()
}

New:

// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  const project = await db.project.findUnique({ where: { id } })
  if (!project) return new NextResponse(null, { status: 404 })
  return NextResponse.json(project)
}

export async function DELETE(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  await db.project.delete({ where: { id } })
  return new NextResponse(null, { status: 204 })
}

The 405-on-other-methods behavior is automatic. Next.js handles unsupported methods for you. Body parsing is no longer configured via export const config; you call await req.json(), await req.formData(), or await req.text() directly. CORS, cookies, and headers all use the Web standard APIs (req.headers.get(...), NextResponse's cookies.set(...)). For a deeper walkthrough including streaming responses and webhook signing, see our Route Handlers guide.

Dynamic routes and the params Promise

The breaking change that bit me hardest in a Next.js 15 upgrade was that params and searchParams became Promises. Every dynamic page, layout, and route handler now receives them async. The codemod handles most call sites, but generateMetadata, generateStaticParams, and deeply nested helpers often slip through.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return { title: post.title, description: post.excerpt }
}

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((p) => ({ slug: p.slug }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article><h1>{post.title}</h1></article>
}

If you forget the await, TypeScript will not always catch it because params.slug on a Promise is undefined at runtime but typed as a property access. Add a lint rule or a small wrapper for this. The same applies to cookies(), headers(), and draftMode(); all of them are async in Next.js 15 and 16. If your migration also crosses the 14-to-15 line, our companion piece on the params Promise migration fix documents the exact PR I ran across 47 pages.

Middleware and the proxy.ts rename

The file once known as middleware.ts is now proxy.ts in Next.js 16. The matcher syntax, the NextRequest/NextResponse APIs, and the edge runtime defaults are unchanged. The rename reflects that this layer is a request proxy, not a per-route middleware in the Express sense. The codemod handles the file move, the export rename (middlewareproxy), and most config tweaks; what it misses are imports in tests and any custom shims you wrote against the old name. Our middleware.ts to proxy.ts migration walkthrough covers the edge cases the codemod leaves behind.

One thing to verify after the rename: if your Pages app set request headers in middleware and read them in getServerSideProps, you now read them in Server Components via headers(). The flow is the same, but the read site moves.

Authentication and session reads

Auth migrations are where I budget the most days. The mechanical changes are small; the semantic ones depend on your auth library. For Auth.js v5 (NextAuth's successor), the session helper now works server-side without context:

// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth()
  if (!session?.user) redirect('/login')
  return <p>Hi {session.user.name}</p>
}

You no longer need to wrap every page with getServerSession calls inside getServerSideProps. The SessionProvider still wraps client trees for useSession(), but server-side reads are direct. If you are still on NextAuth v4, plan the v5 upgrade in the same migration window. Running both at once is painful (ask me how I know). For a complete walkthrough see the Auth.js v5 docs for the v4 to v5 migration.

Effort estimates by route type

From four migrations, here is the rough budget I quote stakeholders. Pad by 30% if your team is new to Server Components.

Route typeEffort (1 engineer)RiskNotes
Static marketing page2–4 hoursLowMostly file move and metadata API.
Read-only authenticated page0.5–1 dayLowReplace getServerSideProps with await; verify session helper.
Form with mutation1–2 daysMediumMove to Server Action; revisit success/error UI.
Dynamic listing with filters1–3 daysMediumDecide between searchParams and client state.
API route with 3+ methods0.5 dayLowPer-method exports; check CORS preflight.
Auth flow (login/signup/reset)3–5 daysHighCoordinate with Auth.js v5 upgrade if pending.
Middleware/proxy logic1–2 daysMediumCodemod handles rename; audit header reads.
Internationalized routes2–4 daysMediumReuse next-intl App Router setup.

For a 60-route mid-sized app with a mix of these, a senior engineer working 70% on migration ships in 4–6 weeks. Trying to "sprint" the migration in two weeks always overruns; sequencing PRs route-by-route and shipping continuously is faster than a freeze.

Common migration gotchas

The bugs that have cost me real hours, in rough order of frequency:

  • Forgetting await params. The page renders with an undefined slug, returns 404, and you spend an hour staring at the database before realizing.
  • Putting 'use client' at the root layout. This forces the whole tree to be a Client Component and you lose all the bundle savings. Move the boundary down to a small Providers wrapper.
  • Mixing next/router with next/navigation. The Pages router hook is useRouter from next/router; the App router hook is also useRouter, but from next/navigation. Aliasing one explicitly avoids accidental wrong imports.
  • Stale fetch caching. Next.js 16 changed cache defaults to opt-in. If you rely on the implicit Pages-era caching, you may see traffic surge against your backend until you add "use cache" or next: { revalidate }.
  • Server Components in pages/. Server Components only work in app/. Importing a Server Component from a Pages route silently degrades it to a regular component and the error message is unhelpful.
  • Hydration mismatches from typeof window. The Pages-era trick to render different markup on server vs client throws hydration errors in the App Router's stricter checker. Use useEffect or a dynamic import with ssr: false.
  • Old global CSS imports in components. Pages allowed global CSS only in _app.tsx; App allows it only in app/layout.tsx. Mid-tree global imports throw a build error.

Frequently Asked Questions

Is the Pages Router being deprecated in Next.js 16?

No. The Pages Router ships in Next.js 16, is fully supported, and Vercel has not announced a sunset date. You can stay on it indefinitely if you do not need App Router features.

Can I run Pages Router and App Router in the same project?

Yes. pages/ and app/ directories coexist in the same Next.js app. The App Router wins for routes defined in both. This is the recommended way to migrate incrementally.

How long does a Pages-to-App migration take?

Budget 4–6 weeks for a 60-route mid-sized app with one senior engineer at 70% allocation, shipping route-by-route. Small marketing sites take 1–2 weeks; complex SaaS dashboards with custom auth can run 8–12 weeks.

Is there a codemod to migrate Pages to App Router?

There is no end-to-end codemod. @next/codemod handles version bumps, the middleware.ts to proxy.ts rename, and the params Promise conversion, but the data-fetching rewrite from getServerSideProps to Server Components is too semantic to automate.

Do I need to rewrite my API routes?

Only if you also want to move them under app/api/ as Route Handlers. Pages API routes under pages/api/ keep working in Next.js 16. Most teams migrate API routes last to reduce risk.

What replaces getServerSideProps in the App Router?

An async Server Component that calls await fetch() or your database client directly. There is no named export; the data fetching lives inline in the component body and renders on the server.

Jasmine Patel
About the Author Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.