
Next.js Migrating from Pages to the App Router
If you have a Next.js application that predates the App Router, you are sitting on more technical debt than you probably realize. The pages directory still works, and Next.js has no plans to rip it out from under you, but every new feature the framework ships — Server Components, streaming, the Metadata API, Server Actions, Cache Components — targets the app directory first. Staying on pages indefinitely means slowly drifting away from where the framework, its documentation, and its ecosystem are all heading.
The good news is that migrating is not an all-or-nothing event. Next.js was deliberately built so the app and pages directories can coexist in the same project, which means you can move one route at a time, ship after every step, and never have a multi-week branch that blocks the rest of your team. This guide walks through that incremental path in the order I'd actually do it, not just the order the mental model happens to be easiest to explain in.
Why Incremental Beats Big Bang
I've seen teams attempt the "stop everything, migrate all routes this sprint" approach, and it almost always goes worse than planned. The App Router isn't a syntax change — it introduces a genuinely different rendering model (Server Components by default, a different data-fetching API, a different router). Every route you touch is an opportunity to introduce a regression, and if you migrate fifty routes before shipping any of them, you've built a giant pile of unverified risk that all lands at once.
The incremental path works because app and pages are resolved by the same file-system router simultaneously. A request for /dashboard will be served by app/dashboard/page.tsx if it exists, and falls back to pages/dashboard.js otherwise. That means you can migrate your lowest-traffic, lowest-risk route first, verify it in production, and use what you learn to make the next ten routes faster.
Before You Touch Any Routes
Bump Your Node and Next.js Versions
The App Router requires a modern Node.js runtime — Node 18.17 or later at a minimum, though if you're running Next.js 16 as this project does, you're almost certainly already well past that. Update Next.js, React, and React DOM together:
npm install next@latest react@latest react-dom@latest
If you're on ESLint, update eslint-config-next too, and restart the ESLint server in your editor afterward — stale ESLint processes will keep flagging App Router patterns as errors from rules that no longer apply.
npm install -D eslint-config-next@latest
Know What Changes Even If You Never Touch app
Some of the version bump comes with improvements that work in pages too, and you get them for free without migrating anything:
next/imagebehavior improved (less client JS, native lazy loading) — this became the default behavior, so double-check any custom image styling you had layered on top of the old behavior.next/linkno longer requires (or accepts, depending on version) a nested<a>child.<Link href="/about">About</Link>just works now.next/fontreplaced the old inlined-font-CSS approach, and it works in both directories.
Take these as a free warm-up lap: applying them to your existing pages routes gets your codebase partway modernized before you touch the harder architectural changes.
The Mental Model Shift, In One Table
Before writing any app code, it's worth internalizing what maps to what. This is the table I wish someone had put in front of me before my first migration:
| Pages Router | App Router | Purpose |
|---|---|---|
pages/_app.js + pages/_document.js | app/layout.tsx (root layout) | Shared shell for the whole app |
pages/_error.js | app/error.tsx | Error boundaries, now per-segment |
pages/404.js | app/not-found.tsx | Not-found UI |
pages/api/*.js | app/**/route.ts | Server-side request handlers |
getServerSideProps | fetch(url, { cache: 'no-store' }) in a Server Component | Per-request data |
getStaticProps | fetch(url) (default force-cache) in a Server Component | Build-time cached data |
getStaticPaths | generateStaticParams | Which dynamic segments to prerender |
useRouter (from next/router) | useRouter, usePathname, useSearchParams (from next/navigation) | Client-side routing |
| Pages are Client Components by default | Pages are Server Components by default | The single biggest behavioral flip |
That last row is the one people underestimate. In pages, every component you write executes on the client after hydration, even if the initial HTML was server-rendered. In app, everything is a Server Component unless you explicitly opt out with 'use client'. This isn't a cosmetic difference — it changes where your useState, useEffect, and event handlers are allowed to live.
Step 1: Create the app Directory and Root Layout
Start by creating an app folder at your project root (or inside src/ if that's your convention). The very first thing it needs is a root layout, because unlike pages, Next.js does not automatically wrap your app in <html> and <body> tags in the App Router — you have to do it yourself, exactly once, at the root.
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
If you have global styles or context providers in pages/_app.tsx, copy — don't move — them into this layout for now. Styles declared in app/layout.tsx don't apply to anything still living in pages/*, so keeping the old _app/_document files in place while you migrate prevents you from breaking routes you haven't gotten to yet. Delete them only once every route has moved.
Any React Context providers from _app.js need to become Client Components, since providers rely on useState/useContext under the hood:
// app/providers.tsx
"use client";
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// 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 pattern — a thin 'use client' wrapper imported into a Server Component layout — is one you'll reuse constantly during this migration. It lets you keep the root layout itself as a Server Component while still supporting client-side providers.
Replacing getLayout() Patterns
If your pages codebase used the informal Page.getLayout convention to compose per-page layouts, the App Router replaces it with real nested layouts — no more monkey-patching a function property onto your page component. Move the layout JSX into an actual layout.js file colocated with the routes it should wrap, and delete the getLayout property from the page.
Step 2: Replace next/head with the Metadata API
next/head still technically renders in pages, but it doesn't work inside app at all — you need the built-in Metadata API instead. This is a straight swap, not a redesign:
// Before — pages/index.tsx
import Head from "next/head";
export default function Page() {
return (
<Head>
<title>My page title</title>
</Head>
);
}
// After — app/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Page Title",
};
export default function Page() {
return "...";
}
The Metadata API covers far more than <title> — Open Graph tags, robots directives, icons, canonical URLs — and it's statically analyzable, which is part of why Next.js can generate a lot of the SEO boilerplate for you automatically once you're on it.
Step 3: Migrate Your First Page
This is the step people psych themselves out over, and it's actually the most mechanical part of the whole process. The recommended pattern — and the one with the least behavioral surprise — is a two-file split:
Move your existing page component into a Client Component, unchanged in spirit:
// app/home-page.tsx
"use client";
export default function HomePage({ recentPosts }: { recentPosts: Post[] }) {
return (
<div>
{recentPosts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
Create a new page.tsx that fetches data as a Server Component and renders your Client Component:
// app/page.tsx
import HomePage from "./home-page";
async function getPosts() {
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default async function Page() {
const recentPosts = await getPosts();
return <HomePage recentPosts={recentPosts} />;
}
Why bother splitting instead of just porting the whole page directly? Because your original pages/index.js almost certainly used useState, useEffect, or an event handler somewhere — and none of those work in a Server Component. Wrapping the existing component as-is in a Client Component gets you a working, behaviorally-identical page on day one. You can then incrementally push pieces of it back down into Server Components later, once you understand which parts actually need interactivity and which were only Client Components because the old model forced everything to be.
Don't skip that second pass. I've seen migrations "complete" where literally every page is one giant 'use client' component with a thin Server Component wrapper around it — which technically satisfies "we migrated to App Router" while throwing away nearly all of the performance benefit that was the point of migrating in the first place.
Step 4: Migrate Routing Hooks
useRouter imported from next/navigation is a different hook from the one you know from next/router, and the differences are not cosmetic:
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export default function Nav() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// ...
}
The new useRouter no longer returns pathname or query — those are now separate hooks (usePathname, useSearchParams, and useParams for dynamic route segments). isFallback, locale/locales, basePath, asPath, isReady, and route are all gone, mostly because the concepts they represented (blocking fallback, built-in i18n routing, as paths) don't exist in the same form in the App Router. And crucially, these hooks only work inside Client Components — there's no equivalent for Server Components, because a Server Component has no client-side router state to read.
If you have components that need to work in both pages and app during the transition period, next/compat/router exports a version of useRouter designed specifically for that in-between state. It's a bridge, not a destination — plan to drop it once every route has moved.
Step 5: Migrate Data Fetching
This is the part with the most code to change, but conceptually it's the simplest: getServerSideProps, getStaticProps, and getStaticPaths are gone, replaced by fetch() calls directly inside async Server Components, differentiated by cache options rather than by which exported function you wrote.
getServerSideProps → fetch with no-store:
// app/dashboard/page.tsx
async function getProjects() {
const res = await fetch("https://api.example.com/projects", {
cache: "no-store",
});
return res.json();
}
export default async function Dashboard() {
const projects = await getProjects();
return (
<ul>
{projects.map((p: Project) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
getStaticProps → fetch with the default cache behavior, which is force-cache unless you say otherwise — the request is cached at build time and reused until you explicitly revalidate it.
getStaticProps with revalidate → fetch with next: { revalidate }:
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
return res.json();
}
getStaticPaths → generateStaticParams:
// app/posts/[id]/page.tsx
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
export default async function Post({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return <PostLayout post={post} />;
}
Notice params is a Promise here — that's a Next.js 15+ change (not part of the original App Router migration itself, but relevant if you're migrating directly onto a current version): route params, search params, and a few other request-time APIs became async to support the newer prerendering models. Await them before use.
The fallback: true | false | 'blocking' option from getStaticPaths maps to the dynamicParams route segment config: true (the default) generates unlisted segments on demand, false returns a 404 for anything not covered by generateStaticParams. There's no direct equivalent of fallback: 'blocking' because, with streaming, the practical difference between blocking and true disappears.
Reading Cookies and Headers
If your getServerSideProps read req.cookies or req.headers, the App Router replaces direct Node request access with two dedicated async functions:
import { cookies, headers } from "next/headers";
export default async function Page() {
const theme = (await cookies()).get("theme");
const authHeader = (await headers()).get("authorization");
return "...";
}
Step 6: Migrate API Routes to Route Handlers
pages/api/* routes keep working untouched — Next.js has no plans to remove them. But new endpoints in app use Route Handlers instead, built on the standard Web Request/Response APIs rather than the Node-style (req, res) signature:
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: "Hello" });
}
One thing worth calling out that the migration guide undersells: if your old API routes existed mainly so client-side code could hit an internal endpoint to reach an external API, you often don't need a Route Handler at all anymore. A Server Component can fetch that external API directly and pass the result down as props — cutting out a network hop entirely. Only reach for a Route Handler when you genuinely need an HTTP endpoint: a webhook target, something called from outside your app, or a response format other than what a component render can produce.
Step 7: Styling
The pages directory restricted global stylesheets to pages/_app.js only. That restriction is gone in app — global styles can be imported from any layout, page, or component, though in practice you'll still usually want one canonical import in the root layout to avoid duplicate stylesheet loads.
If you're on Tailwind CSS, make sure your content globs (or in Tailwind v4, your @source directives) include the app directory alongside pages:
// tailwind.config.js (Tailwind v3-style config)
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
};
CSS Modules, Sass, and CSS-in-JS libraries all continue to work, though CSS-in-JS libraries specifically need a bit of extra setup to collect styles correctly during server rendering — that's involved enough to be its own guide rather than a footnote here.
What the Docs Don't Emphasize Enough
Navigating between the two routers is a hard navigation. While you have both app and pages routes live in the same project, moving from a page served by one router to a page served by the other triggers a full page reload — next/link prefetching doesn't cross that boundary. Your users will notice a page served by app feels snappier than one served by pages, and the transition between the two feels like leaving the SPA experience entirely, if only for that one hop. This is a real, measurable UX regression during the migration window, not a hypothetical one — plan your migration order so high-traffic navigation paths (like your primary nav) land on the same router as early as possible, rather than leaving your busiest cross-links straddling both.
Prerendered Client Components on first load aren't "no SSR." A page component in app/home-page.tsx marked 'use client' still gets prerendered to static HTML on the initial request — it isn't purely client-rendered like a create-react-app SPA. This matters for SEO: your interim, not-yet-optimized migration state still produces crawlable HTML, so you don't need to rush the "push logic back into Server Components" cleanup pass purely out of SEO fear. Do it for performance and bundle size, on your own timeline.
Test each migrated route against production traffic patterns, not just happy-path clicks. The most common regression I've seen isn't in the code you write — it's in edge cases the old getServerSideProps handled implicitly that you have to handle explicitly now: a missing query param that used to just come back as undefined in context.query, now needs an explicit check against useSearchParams() returning null. Trailing-slash and locale-prefixed URLs sometimes resolve differently between the two routers if you had custom next.config.js rewrites written with pages-only assumptions.
Don't migrate your error and 404 pages last. It's tempting to leave pages/_error.js and pages/404.js in place until the very end since they're not "real" routes. But app/error.tsx and app/not-found.tsx behave meaningfully differently — error boundaries are now scoped per route segment instead of global, which is a strict upgrade (a crash in one part of your UI doesn't take down the whole page) but only if you've actually placed error.tsx files at the right levels of your route tree. Treat this as a deliberate design step, not cleanup.
Resist migrating for the sake of migrating. Not every route needs the App Router today. A route that's rarely visited, has no interactivity, and isn't blocking any new feature work can reasonably stay on pages for a while longer. The App Router isn't going anywhere in the next release, and spending migration effort on your highest-value routes first — your homepage, your primary conversion funnel, anything currently blocked on a feature that only app supports — is a better use of a sprint than mechanically working top-to-bottom through a file tree.
Key Takeaways
| If you're migrating... | Replace it with |
|---|---|
pages/_app.js + _document.js | app/layout.tsx (root layout) |
next/head | The Metadata API (export const metadata) |
getServerSideProps | fetch(url, { cache: 'no-store' }) |
getStaticProps | fetch(url) (default cached) |
getStaticProps with revalidate | fetch(url, { next: { revalidate } }) |
getStaticPaths | generateStaticParams + dynamicParams |
useRouter from next/router | useRouter/usePathname/useSearchParams from next/navigation |
pages/api/* | Route Handlers (app/**/route.ts), only where you truly need an HTTP endpoint |
Page.getLayout() | Native nested layout.js files |
Migrate one route at a time, keep _app/_document alive until the last pages route is gone, and don't treat "wrapped my old component in 'use client'" as the finish line — it's the safe first step, not the destination. The App Router's real payoff, less client JavaScript and faster page loads, only shows up once you go back and push logic down into Server Components where it belongs.


