· nextjs / app-router / pages-router

How to migrate from Next.js Pages Router to App Router

Migrate Next.js Pages Router to App Router on 16.2.6: root layout, data fetching, Route Handlers, metadata API, and 8 real gotchas with fixes.

By

2,719 words · 14 min read

If your Next.js project is still on the Pages Router, the App Router gives you React Server Components, streaming, parallel routes, and a data-fetching model that doesn’t require three different functions depending on whether your data is dynamic. The migration is worth it. It’s also non-trivial — plan for 4–8 hours per route on your first pass.

The coexistence strategy is the key insight: pages/ and app/ can run side-by-side. You migrate one route at a time. Nothing breaks while you work.

Who this is for

Teams running Next.js 13.4 or later who want to move off the Pages Router. You should already be comfortable with TypeScript and Next.js basics. If you’re on Next.js 12 or earlier, upgrade to 16 first using npx @next/codemod@latest upgrade — running that codemod before this migration saves you from hitting version-specific issues on top of the router-model issues.

If you have a micro-frontend setup with module federation, this guide won’t cover that case.

If you’re still evaluating whether to stay on Next.js at all, see our SvelteKit vs Next.js head-to-head and Next.js 16 vs React Router v7 comparison for a 2026 framework perspective before committing to the migration effort.

What we tested

  • Next.js 16.2.6 — current stable as of May 2026
  • Node.js 22.14.0 — minimum supported is v20.9.0
  • TypeScript 5.8.3
  • Reference project: a medium-sized app with SSR product pages, static blog posts, a multi-step form, and four API endpoints

Conceptual map — Next.js Pages Router vs App Router

Before touching any files, understand the model change:

Pages RouterApp Router
pages/index.tsxapp/page.tsx
pages/about.tsxapp/about/page.tsx
pages/_app.tsxapp/layout.tsx
pages/_document.tsxapp/layout.tsx (merged)
getServerSidePropsasync Server Component (or fetch with no caching)
getStaticPropsasync Server Component with "use cache"
getStaticPathsgenerateStaticParams
pages/api/hello.tsapp/api/hello/route.ts
next/headexport const metadata
useRouter from next/routeruseRouter from next/navigation

The data-fetching change is the biggest mental shift. In the Pages Router, data fetching happens in special functions exported from the same file as the component. In the App Router, every component is a Server Component by default — you await your data directly in the component body.

Pre-migration checklist

Run these checks before writing any new files:

node -v        # must be ≥ 20.9.0
npx next --version  # must be ≥ 13.4.0 for coexistence to work

Audit your dependencies for Pages Router assumptions:

npx next info

Things to flag before starting:

  • Any package that wraps getServerSideProps or getInitialProps — these won’t translate automatically
  • Custom _document.tsx with non-standard <body> attributes — you’ll move these to app/layout.tsx
  • next-i18next — swap for next-intl which has first-class App Router support
  • Session libraries using getServerSideProps for auth gating — you’ll replace with Middleware or auth() in Server Components

Step 1 — Create the app/ directory

Create app/ at the root of your project alongside pages/. Next.js detects the app/ directory automatically; you don’t need to change any config.

my-app/
├── app/           ← new
│   └── layout.tsx ← required immediately
├── pages/         ← stays for now
│   ├── index.tsx
│   └── about.tsx
└── next.config.ts

The app/layout.tsx is mandatory as soon as the app/ directory exists. Without it, Next.js throws an error.

Step 2 — Migrate the root layout

Your Pages Router root layout lives across two files: pages/_app.tsx (global providers, global styles) and pages/_document.tsx (HTML shell, <head> defaults, body attributes). Both collapse into app/layout.tsx.

Before — pages/_app.tsx:

import type { AppProps } from 'next/app';
import '../styles/globals.css';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <div className="app-shell">
      <Component {...pageProps} />
    </div>
  );
}

Before — pages/_document.tsx:

import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <body className="bg-gray-50">
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

After — app/layout.tsx:

import type { Metadata } from 'next';
import '../styles/globals.css';

export const metadata: Metadata = {
  icons: { icon: '/favicon.ico' },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-50">
        <div className="app-shell">{children}</div>
      </body>
    </html>
  );
}

If you have a global Redux store, React Query provider, or theme provider, wrap children here — but mark the layout 'use client' if those providers use hooks internally. Better: extract providers into a Providers client component and import it in the server layout:

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

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

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

This keeps the layout itself as a Server Component while still letting client-side libraries work.

Step 3 — Migrate pages one by one

Pick the lowest-complexity route first — usually a static page with no data fetching. Move it, verify it works, then tackle a data-fetching page.

Before — pages/about.tsx:

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

After — app/about/page.tsx:

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

During coexistence, if the same route exists in both pages/about.tsx and app/about/page.tsx, Next.js warns in development and uses app/ in production. Once you verify the App Router version works, delete the pages/ version.

Dynamic routes

Before — pages/products/[id].tsx:

import { GetStaticPaths, GetStaticProps } from 'next';

export const getStaticPaths: GetStaticPaths = async () => {
  const ids = await fetchProductIds();
  return {
    paths: ids.map((id) => ({ params: { id: String(id) } })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const product = await fetchProduct(params!.id as string);
  return { props: { product }, revalidate: 60 };
};

export default function ProductPage({ product }: { product: Product }) {
  return <ProductDetail product={product} />;
}

After — app/products/[id]/page.tsx:

import { fetchProduct, fetchProductIds } from '@/lib/api';

export async function generateStaticParams() {
  const ids = await fetchProductIds();
  return ids.map((id) => ({ id: String(id) }));
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await fetchProduct(id);
  return <ProductDetail product={product} />;
}

Note params is a Promise in Next.js 16 — you must await it. This is a common gotcha covered below.

For fallback: 'blocking' behavior, add this export to your page file:

export const dynamicParams = true; // default — generates missing params on request

For fallback: false (404 on unknown params), set dynamicParams = false.

Step 4 — Replace data-fetching patterns

getServerSideProps → async Server Component

// Before
export const getServerSideProps = async ({ params }) => {
  const data = await fetchData(params.id);
  return { props: { data } };
};

export default function Page({ data }) { ... }
// After
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const data = await fetchData(id);
  return <YourComponent data={data} />;
}

In Next.js 16, fetch is uncached by default. This matches the original getServerSideProps behavior (run on every request). No extra config needed.

getStaticProps"use cache" directive

Before using "use cache" with cacheLife, enable the cacheComponents flag in next.config.ts:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Without this flag, "use cache" + cacheLife silently fails — the data is fetched on every request.

// Before
export const getStaticProps = async () => {
  const posts = await fetchBlogPosts();
  return { props: { posts }, revalidate: 3600 };
};
// After
import { cacheLife } from 'next/cache';

export default async function BlogIndex() {
  'use cache';
  cacheLife('hours');

  const posts = await fetchBlogPosts();
  return <PostList posts={posts} />;
}

cacheLife('hours') maps to a profile that revalidates around every hour. Available profiles: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'. For exact TTL control: cacheLife({ revalidate: 3600, expire: 86400 }).

getStaticPathsgenerateStaticParams

Shown in Step 3. The key differences:

  • Return an array of param objects directly (not { paths: [...] })
  • No fallback key — use dynamicParams export instead
  • No context argument — it’s a standalone async function

Step 5 — Migrate API routes to Route Handlers

Before — pages/api/products.ts:

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 products = await fetchProducts();
  res.status(200).json(products);
}

After — app/api/products/route.ts:

import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const products = await fetchProducts();
  return NextResponse.json(products);
}

Route Handlers use named exports (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) instead of a single default export with method branching. Non-exported methods return 405 automatically.

For dynamic route segments:

// app/api/products/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const product = await fetchProduct(id);
  if (!product) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  return NextResponse.json(product);
}

Step 6 — Replace next/head with the metadata API

Before — in any page file:

import Head from 'next/head';

export default function ProductPage({ product }: { product: Product }) {
  return (
    <>
      <Head>
        <title>{product.name} — MyShop</title>
        <meta name="description" content={product.description} />
        <meta property="og:title" content={product.name} />
      </Head>
      <ProductDetail product={product} />
    </>
  );
}

After — static metadata:

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About — MyShop',
  description: 'Our story and team.',
};

export default function AboutPage() { ... }

After — dynamic metadata:

import type { Metadata } from 'next';
import { fetchProduct } from '@/lib/api';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const product = await fetchProduct(id);
  return {
    title: `${product.name} — MyShop`,
    description: product.description,
    openGraph: {
      title: product.name,
      images: [product.imageUrl],
    },
  };
}

Next.js deduplicates the metadata across nested layouts automatically.

Common gotchas and fixes

1. useRouter import path changed

// Wrong — silently breaks client-side navigation
import { useRouter } from 'next/router';

// Correct
import { useRouter } from 'next/navigation';

The next/navigation router also has different API: router.push works, but router.query doesn’t exist. Use useSearchParams() for query params and useParams() for dynamic segments.

2. params and searchParams are Promises in Next.js 16

// This will fail at runtime in Next.js 16
export default function Page({ params }: { params: { id: string } }) {
  const { id } = params; // TypeError: params is not an object
}

// Correct
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
}

In client components where you can’t await, use React.use():

'use client';
import { use } from 'react';

export default function ClientPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  return <div>{id}</div>;
}

3. cookies() and headers() are async

// Wrong
import { cookies } from 'next/headers';
const token = cookies().get('token');

// Correct
import { cookies } from 'next/headers';
const cookieStore = await cookies();
const token = cookieStore.get('token');

4. Client Component boundary — where to put 'use client'

Every component that uses hooks (useState, useEffect, useContext) or browser APIs needs 'use client'. The directive only needs to go at the top of the file where the hook/API first appears — child components are automatically treated as client components too.

Don’t spray 'use client' everywhere. Keep Server Components as deep as possible in the tree; push the boundary down to the interactive leaf nodes.

5. Global CSS must be imported only in app/layout.tsx

In the Pages Router, you could import global CSS in _app.tsx. In the App Router, global CSS imports are only allowed in layout files, not in page files or components. If you try to import globals.css in a non-layout file, you get:

Error: Global CSS cannot be imported from files other than your Custom <App>.

Move all global imports to app/layout.tsx.

6. Middleware is deprecated in Next.js 16 — rename it to proxy.ts

In Next.js 16, middleware.ts is deprecated in favor of proxy.ts. The old file still runs (it hasn’t been removed), so nothing breaks immediately — but a migration to Next.js 16 should fix this.

Rename middleware.tsproxy.ts at the project root. Rename the exported function from middleware to proxy:

// Before: middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // your logic
  return NextResponse.next();
}
// After: proxy.ts
import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  // your logic
  return NextResponse.next();
}

The file must stay at the project root — moving it to app/proxy.ts silently disables it.

7. getInitialProps blocks static optimization entirely

If you migrated a page that used getInitialProps, the App Router equivalent doesn’t exist — there’s no getInitialProps in RSC. If a library you depend on adds getInitialProps (some older auth libraries do this), you’ll need to refactor or replace the library. The codemod flags these but can’t auto-fix them.

8. Default fetch caching changed — uncached by default

In the Pages Router, getStaticProps pages were statically generated by default. In Next.js 16, fetch() in Server Components is uncached by default. If you ported a page expecting it to be static but didn’t add "use cache", every request hits your data source.

Check your Vercel Function invocation logs after migration — unexpected spikes usually point to this.

Deployment on Vercel

The App Router works on Vercel without any config changes. Incremental migration is explicitly supported: routes in pages/ deploy as Lambda functions, routes in app/ deploy as Edge or Node.js functions based on your runtime setting.

After deploying an incremental migration, check:

  • Functions tab: App Router routes should appear as separate functions, not bundled with Pages Router routes
  • Logs tab: Filter by route to confirm await params and await cookies() patterns aren’t throwing

To force a route to Edge runtime (lowest latency, no Node.js APIs):

// app/some-route/page.tsx
export const runtime = 'edge';

Omit this and you get Node.js runtime by default.

Verdict

Teams doing a build tooling upgrade alongside this migration can pair it with the Webpack to Vite migration guide to tackle both in the same sprint.

Migrate incrementally. Start with static pages, then data-fetching pages, then API routes. The coexistence strategy means you can ship the App Router for one route at a time without blocking the team.

The biggest productivity hit comes from the async params / cookies() / headers() pattern — it’s different from every prior version of Next.js and the TypeScript types don’t always catch the mistake until runtime. Run the codemod first (npx @next/codemod@latest upgrade), check the output carefully, and test each migrated route before moving to the next.

Full migration of a 20-route app takes 2–4 days. The result is simpler data fetching, real streaming, and access to RSC patterns that cut client bundle size significantly on data-heavy pages.

Caveats

  • Tested on Next.js 16.2.6 — the params as Promise behavior was introduced in v15 and will likely remain for the life of the App Router. If you’re on 13.x, params is still a plain object.
  • We didn’t test with i18n routing. If you’re using next-i18next, read its migration guide separately — the App Router i18n story is different.
  • Module federation is out of scope entirely.

References