---
title: 'Next.js Pages Router to App Router: A 2026 Migration Tutorial'
description: 'Hands-on 2026 tutorial: migrate a Next.js 16 app from Pages Router to App Router — codemods, async params, route handlers, and the legacyBehavior trap.'
url: https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial
language: en
date: '2026-05-06'
category: frontend
tags:
  - nextjs
  - app-router
  - pages-router
  - migration
  - react
  - typescript
  - server-components
  - frontend
markdown_url: https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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.4[^1] 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 async[^2], 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

By the end you'll know how to verify your toolchain meets Next.js 16's hard floor (Node.js 20.9.0+ and TypeScript 5.1.0+)[^3], let `pages/` and `app/` coexist on the same domain so you can ship the migration one route at a time, run the `@next/codemod` recipes to mechanically rewrite the breaking changes you'd otherwise hit by hand, collapse `_app.tsx` plus `_document.tsx` into a single root layout, port a Pages Router API handler to a Route Handler with the new `NextRequest` / `NextResponse` shape and the now-async `params`, swap every `next/router` hook for the right `next/navigation` replacement, replace `getStaticProps` / `getServerSideProps` / `getStaticPaths` with their App Router equivalents, and avoid the `legacyBehavior` Link trap that breaks builds the moment you upgrade to 16.0[^4]. Verification commands at every step let you confirm the migration actually worked.

## 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 newer[^3]; 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.

```bash
# 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, 2026[^1]) and which package manager to use. After it exits, verify the resolved versions:

```bash
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:

```bash
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 throws[^2]. The codemod handles this mechanically:

```bash
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 instead[^11].
- 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)`[^12].

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 support[^6]. 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 file[^7]:

```tsx
// 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 live[^15]. **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" error[^13], 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:

```tsx
// 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:

```tsx
// 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: { ... } }` wrapper[^8]. `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` helpers[^9]:

```ts
// 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);
}
```

```ts
// 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:

```bash
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 hooks[^10]:

```tsx
// 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>
  );
}
```

```tsx
// 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`.

## Step 7 — Fix the legacyBehavior Link trap

Next.js 16 dropped support for the `legacyBehavior` prop on `<Link>` entirely[^4]. 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:

```tsx
// 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:

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

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

```bash
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:

```bash
# 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' } }]`[^8].

## 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](https://nerdleveltech.com/mastering-nextjs-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](https://nerdleveltech.com/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](https://nerdleveltech.com/mastering-vercel-ai-sdk-v6-building-smarter-scalable-ai-apps) ties the new streaming primitives together with the App Router.

## Related reads

- [Build an Accessible React Combobox with React Aria (2026)](https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial)

[^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]: "Dynamic APIs are Asynchronous" + Codemods reference — https://nextjs.org/docs/messages/sync-dynamic-apis and https://nextjs.org/docs/app/guides/upgrading/codemods
[^3]: Next.js 16 upgrade guide (Node.js 20.9 minimum, TypeScript 5.1 minimum) — https://nextjs.org/docs/app/guides/upgrading/version-16
[^4]: GitHub Discussion #80179, "NextLink legacyBehavior deprecated and removed in next 16" — https://github.com/vercel/next.js/discussions/80179
[^5]: Next.js minimum React version note — https://nextjs.org/docs/messages/react-version
[^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]: `generateStaticParams` API reference — https://nextjs.org/docs/app/api-reference/functions/generate-static-params
[^9]: "Getting Started: Route Handlers" — https://nextjs.org/docs/app/getting-started/route-handlers
[^10]: `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
[^11]: `cookies()` API reference (Server Components / Server Functions / Route Handlers / middleware) — https://nextjs.org/docs/app/api-reference/functions/cookies
[^12]: `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
[^13]: 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
[^14]: "Dynamic APIs are Asynchronous" error reference — https://nextjs.org/docs/messages/sync-dynamic-apis
[^15]: `next/script` Component reference (`beforeInteractive` placement rules) — https://nextjs.org/docs/app/api-reference/components/script
