Next.js Pages Router to App Router: A 2026 Migration Tutorial

May 6, 2026

Next.js Pages Router to App Router: A 2026 Migration Tutorial

TL;DR

This tutorial walks through migrating a real Next.js Pages Router app (pages/) to the App Router (app/) on Next.js 16.2.41 in roughly forty-five minutes. You will let the two routers coexist, run the official @next/codemod to convert sync params / searchParams / cookies() / headers() / draftMode() to async2, move _app.tsx and _document.tsx into a single app/layout.tsx, port one pages/api/* handler to an App Router Route Handler, replace next/router with the three next/navigation hooks, and swap getStaticPaths for generateStaticParams. By the end you will have a hybrid app that ships, plus a clear path to delete pages/ when the last route is gone.

What you'll learn

Prerequisites

Pin these versions before you start. Older releases will surface ABI and config errors that look like migration bugs but are really toolchain mismatches.

  • Node.js 20.9.0 or newer (Node.js 18 is no longer supported on Next.js 16)3. The Node.js 22 and 24 LTS lines are both fine.
  • TypeScript 5.1.0 or newer3; the latest stable in the 5.x or 6.x line works equally well.
  • npm 10+ (or a recent pnpm or yarn release) — codemod recipes are run via npx.
  • An existing Pages Router project on Next.js 14.x or 15.x. If you're on 13.x, do npm i next@15 first and resolve type errors before chasing the App Router; mixing two major upgrades in one PR is the fastest way to lose a day.
  • Git working tree clean. Every codemod below mutates files; you want a single revertable commit per step.

Step 1 — Confirm Next.js 16 is what you think it is

Bump dependencies before touching any source code. The @next/codemod upgrade tool will edit package.json, tsconfig.json, and a handful of config files for you.

# Inside the project root
npx @next/codemod@latest upgrade latest

This is interactive — it asks which version to target (pick latest, which resolves to 16.2.4 as of April 15, 20261) and which package manager to use. After it exits, verify the resolved versions:

node -p "require('./package.json').dependencies.next"
# "16.2.4"

node -p "require('./package.json').dependencies.react"
# "19.2.5"  — the App Router uses React 19's stable surface internally[^5]

npx tsc --version
# Version 5.x or 6.x — anything from 5.1.0 onward satisfies the floor

If next resolved to 15.x, the upgrade codemod silently chose conservative pinning. Force the bump explicitly:

npm i next@16.2.4 react@19.2 react-dom@19.2

Don't run next dev yet — you'll hit the async-API breaking changes before you've migrated anything. Step 2 fixes that.

Step 2 — Apply the async Request API codemod

Next.js 15 introduced async cookies(), headers(), draftMode(), params, and searchParams as a soft change with a synchronous compatibility shim. Next.js 16 removes the shim entirely, so any sync access throws2. The codemod handles this mechanically:

npx @next/codemod@latest next-async-request-api .

The codemod scans every TS/JS file under the current directory and rewrites four patterns:

  • Server Components that read props.params directly now await props.params.
  • Pages and Route Handlers that read searchParams similarly add an await.
  • Calls to cookies() / headers() / draftMode() from next/headers become await cookies() and friends. These three APIs are server-only — call them from Server Components, Route Handlers, or Server Actions. They are not callable from Client Components, and inside middleware you use request.cookies and request.headers on the NextRequest object instead4.
  • Client Components that receive params or searchParams as props (which are now Promise<...>-typed) get the value unwrapped with use() from React: const { slug } = use(params)5.

Run a git diff after the codemod and skim it. The transformation is deterministic but loses a battle in two places worth fixing by hand:

  • Functions that destructure params in their signature, e.g. function Page({ params }: { params: { slug: string } }). The codemod usually rewrites the destructure but sometimes leaves the type annotation as the synchronous shape; update the type to Promise<{ slug: string }>.
  • Places where params.slug was used inside a non-async callback (a .then() chain, a useMemo dependency). You'll get a TypeScript error there until you push the await up to the nearest async boundary.

After cleanup, run npx tsc --noEmit and fix every red squiggle the codemod missed. This is the single highest-leverage step of the whole migration; do it before adding any new App Router files so you can localize blame if something breaks.

Step 3 — Let pages/ and app/ coexist

Next.js explicitly supports running both routers in the same project, and the team has committed to long-term Pages Router support6. That coexistence is what makes incremental migration safe.

Create the app/ directory at the project root (sibling of pages/) and add a placeholder root layout. The root layout replaces both pages/_app.tsx and pages/_document.tsx with a single file7:

// app/layout.tsx
import './globals.css';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My App',
  description: 'Now serving from app/ and pages/',
};

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

Copy global CSS from pages/_app.tsx into app/globals.css (or whatever path the import points to). Move any <Script strategy="beforeInteractive" /> blocks from _document.tsx into the root layout file itself — Next.js automatically injects beforeInteractive scripts into the document <head> regardless of where you place the <Script> element inside the layout, but the layout file is the only place this strategy is allowed to live8. Keep _app.tsx and _document.tsx in place for now — they still serve the routes that haven't moved.

Verify coexistence: npm run dev, then visit any existing Pages Router URL. It should render exactly as before. The two routers do not silently override each other — if app/foo/page.tsx and pages/foo.tsx both resolve to /foo, Next.js fails the build with a "Conflicting routes" error9, so the safe migration pattern is to delete the old pages/foo.tsx in the same commit that adds app/foo/page.tsx.

Step 4 — Port your first leaf route

Pick a low-risk route to migrate first — a static About page, a list view that doesn't use getServerSideProps, or a fetch-once detail page. The pattern is always:

  1. Create app/<route>/page.tsx.
  2. Move the JSX over.
  3. Replace data-fetching helpers with the App Router equivalents.
  4. Delete the old pages/<route>.tsx file last, after the new route renders correctly.

Here's a Pages Router static page:

// pages/blog/[slug].tsx — BEFORE
import type { GetStaticPaths, GetStaticProps } from 'next';

type Props = { post: { title: string; body: string } };

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await fetch('https://api.example.com/posts').then(r => r.json());
  return {
    paths: slugs.map((s: string) => ({ params: { slug: s } })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const post = await fetch(`https://api.example.com/posts/${params!.slug}`).then(r => r.json());
  return { props: { post }, revalidate: 60 };
};

export default function PostPage({ post }: Props) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

The App Router equivalent is shorter and entirely server-rendered:

// app/blog/[slug]/page.tsx — AFTER
export const revalidate = 60; // ISR at the segment level

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

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>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

Three rules cover almost every case. getStaticProps becomes a regular await fetch inside an async Server Component, with the page treated as static by default. getStaticPaths becomes generateStaticParams, which returns a flat array of param objects rather than the older { params: { ... } } wrapper10. getServerSideProps becomes either export const dynamic = 'force-dynamic' at the top of the file, or a per-fetch cache: 'no-store' option — both opt out of caching.

After the new file renders correctly at the same URL, git rm pages/blog/[slug].tsx. Repeat for the next route.

Step 5 — Port one API route to a Route Handler

API routes follow the same coexistence rules. pages/api/users/[id].ts keeps working until you create app/api/users/[id]/route.ts. The new shape uses named HTTP-method exports and the NextRequest / NextResponse helpers11:

// pages/api/users/[id].ts — BEFORE
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' });
  }
  const { id } = req.query;
  const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json());
  return res.status(200).json(user);
}
// app/api/users/[id]/route.ts — AFTER
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json());
  return NextResponse.json(user, { status: 200 });
}

Note three changes that catch most teams. The handler is no longer a default export — every supported HTTP method gets its own named async function GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS export, and unsupported methods return 405 automatically. The dynamic segment is now a Promise, just like in pages, because the same async-request-API rule applies. And res.status().json() is replaced by return NextResponse.json(body, { status }), which composes much better with revalidate, headers, and streaming responses.

Verify with curl from another terminal:

curl -i http://localhost:3000/api/users/42
# HTTP/1.1 200 OK
# content-type: application/json
# {"id":42,"name":"Ada Lovelace"}

Step 6 — Replace next/router with next/navigation

Any Client Component that imports next/router will keep working only as long as that component is rendered through a pages/ route. Once you move it under app/, it must switch to next/navigation and the import surface gets sliced into three hooks12:

// BEFORE — pages/<anywhere>.tsx
'use client';
import { useRouter } from 'next/router';

export function SortToggle() {
  const router = useRouter();
  const sort = (router.query.sort as string) ?? 'newest';
  return (
    <button onClick={() => router.push({ query: { sort: 'oldest' } })}>
      Sort: {sort}
    </button>
  );
}
// AFTER — under app/
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';

export function SortToggle() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const sort = searchParams.get('sort') ?? 'newest';

  const flip = () => {
    const next = new URLSearchParams(searchParams);
    next.set('sort', 'oldest');
    router.push(`${pathname}?${next.toString()}`);
  };

  return <button onClick={flip}>Sort: {sort}</button>;
}

The translation table is short and worth memorizing: router.pathname becomes usePathname(), router.query for query strings becomes useSearchParams(), and router.query for dynamic segments becomes useParams(). All three hooks are Client-Component-only and require 'use client' at the top of the file. router.push / router.replace still exist on useRouter() from next/navigation but accept a string URL rather than a query object — build the URL yourself with URLSearchParams.

Next.js 16 dropped support for the legacyBehavior prop on <Link> entirely13. Any code that still wraps <a> inside <Link legacyBehavior> builds successfully on 15.x and breaks the moment you bump to 16.0. The fix is mechanical:

// BEFORE
<Link href="/about" legacyBehavior>
  <a className="nav-link">About</a>
</Link>

// AFTER
<Link href="/about" className="nav-link">About</Link>

Class names, target, and rel props now go on the <Link> itself (it renders an <a> automatically). Custom components that wrapped an anchor and used passHref need the most thought — without legacyBehavior, you'll often render an invalid <a><a></a></a> pair. The cleanest fix is to push the <Link> outside the wrapper and style the inner element instead.

A quick grep finds every offender:

grep -rn "legacyBehavior" app/ pages/ components/

There is also a dedicated codemod that handles the simple cases:

npx @next/codemod@latest new-link .

The codemod rewrites the obvious wrappers automatically; custom-component wrappers still need manual review. Anything left after both steps must be rewritten before next build will succeed.

Verification

Run the migration's three smoke tests in order:

# 1. Type check passes — catches missed async-Request-API conversions
npx tsc --noEmit

# 2. Lint passes — catches stray next/router imports under app/
npx next lint

# 3. Production build succeeds — catches legacyBehavior and other 16-only changes
npm run build

# 4. Hit a migrated route end-to-end
npm start &
curl -s http://localhost:3000/blog/hello-world | grep -c "<article>"
# expected: 1

If next build fails complaining about a missing slug param for /blog/[slug], your generateStaticParams likely returned the older wrapper shape — flatten it to [{ slug: 'foo' }], not [{ params: { slug: 'foo' } }]10.

Troubleshooting

The "Dynamic APIs are Asynchronous" error (the docs page is at nextjs.org/docs/messages/sync-dynamic-apis)14 — the codemod missed a call site. Add await in Server Components, Route Handlers, and Server Actions. For params / searchParams props in Client Components, unwrap with use() from React. This is by far the most common error after Step 2.

useRouter is not a function-style error inside app/ — you imported from next/router. Switch to import { useRouter } from 'next/navigation'. The two useRouter hooks share a name but have different return shapes; mixing them up is a runtime crash with a misleading stack.

Hydration mismatch after moving _app.tsx providers into app/layout.tsx — React Context Providers must run on the client, but app/layout.tsx is a Server Component by default. Wrap the providers in a separate 'use client' component (app/providers.tsx) and import that into the root layout.

Build fails with a legacyBehavior deprecation/removal message — finish Step 7 before retrying. The error points at a file path; rerun the grep from Step 7 if anything is still missing.

ISR not refreshing after editing revalidate — the build cache under .next/cache survives across next build. Delete .next/cache and rebuild; the new revalidate window only applies to pages built after the change.

Next steps

Once every route lives under app/, delete pages/_app.tsx, pages/_document.tsx, and the now-empty pages/ directory in a final tidy-up commit. From there the App Router unlocks the rest of Next.js 16: streaming with loading.tsx, parallel routes, intercepting routes, and the new caching primitives. For deeper App Router patterns at scale see Mastering Next.js App Router Patterns for Scalable Web Apps; for the rendering model that makes Server Components shine see React Server Components: The Future of Seamless Rendering; and if you're layering AI features on top of the migrated app, Mastering Vercel AI SDK v6 ties the new streaming primitives together with the App Router.

Footnotes

  1. Next.js 16 release blog and 16.2 release notes — https://nextjs.org/blog/next-16 and https://nextjs.org/blog/next-16-2 2

  2. "Dynamic APIs are Asynchronous" + Codemods reference — https://nextjs.org/docs/messages/sync-dynamic-apis and https://nextjs.org/docs/app/guides/upgrading/codemods 2

  3. Next.js 16 upgrade guide (Node.js 20.9 minimum, TypeScript 5.1 minimum) — https://nextjs.org/docs/app/guides/upgrading/version-16 2

  4. cookies() API reference (Server Components / Server Functions / Route Handlers / middleware) — https://nextjs.org/docs/app/api-reference/functions/cookies

  5. page.js file-system convention reference (Promise-typed params / searchParams and use() unwrapping in Client Components) — https://nextjs.org/docs/app/api-reference/file-conventions/page

  6. Vercel team statement on long-term Pages Router support — https://github.com/vercel/next.js/discussions/56655

  7. "Migrating: App Router" official guide (root layout replaces _app + _document) — https://nextjs.org/docs/app/guides/migrating/app-router-migration

  8. next/script Component reference (beforeInteractive placement rules) — https://nextjs.org/docs/app/api-reference/components/script

  9. Next.js project-structure reference (conflicting routes between app/ and pages/ cause a build-time error) — https://nextjs.org/docs/app/getting-started/project-structure

  10. generateStaticParams API reference — https://nextjs.org/docs/app/api-reference/functions/generate-static-params 2

  11. "Getting Started: Route Handlers" — https://nextjs.org/docs/app/getting-started/route-handlers

  12. useRouter (App Router) reference + GitHub Discussion #48426 on the next/router vs next/navigation split — https://nextjs.org/docs/app/api-reference/functions/use-router and https://github.com/vercel/next.js/discussions/48426

  13. GitHub Discussion #80179, "NextLink legacyBehavior deprecated and removed in next 16" — https://github.com/vercel/next.js/discussions/80179

  14. "Dynamic APIs are Asynchronous" error reference — https://nextjs.org/docs/messages/sync-dynamic-apis


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.