
How to Migrate from the Pages Router to the App Router in Next.js?
Plenty of production Next.js apps still run on the Pages Router. They work, they ship, and nobody wants to rewrite them in one go. But every new Next.js feature since version 13, including Server Components, nested layouts, streaming, Server Actions, and the Metadata API, lives in the app directory. The longer a project stays on pages, the wider the gap between how it is built and how the framework, its documentation, and its ecosystem expect it to be built.
The good news is that you do not have to migrate everything at once. The pages and app directories run side by side in the same project, so you can move one route at a time, deploy after each step, and stop halfway if priorities change.
This article covers the full migration path: preparing the project, creating the root layout that replaces _app and _document, swapping next/head for the Metadata API, converting pages and their data fetching functions, replacing next/router hooks, moving API routes to Route Handlers, handling dynamic paths and fallbacks, and the problems that come up most often along the way.
Pages Router vs App Router at a Glance
Before moving files around, it helps to see how the concepts map. Almost everything in the Pages Router has a direct counterpart in the App Router, but the counterpart usually works differently.
| Pages Router | App Router |
|---|---|
pages/index.tsx | app/page.tsx |
pages/blog/[slug].tsx | app/blog/[slug]/page.tsx |
pages/_app.tsx and _document.tsx | app/layout.tsx (root layout) |
Page.getLayout pattern | Nested layout.tsx files |
next/head | metadata export or generateMetadata |
getServerSideProps | async Server Component with uncached data |
getStaticProps | async Server Component with cached data |
getStaticPaths | generateStaticParams |
fallback: true / false / blocking | dynamicParams segment config |
pages/api/* | app/**/route.ts (Route Handlers) |
pages/404.tsx | app/not-found.tsx |
pages/_error.tsx | error.tsx per segment, global-error.tsx |
useRouter from next/router | useRouter, usePathname, useSearchParams, useParams from next/navigation |
The biggest mental shift is the last column's default: every file in app is a Server Component unless you mark it otherwise. In the Pages Router, every page component shipped to the browser and hydrated. In the App Router, a component only ships JavaScript if it, or a file that imports it, starts with the "use client" directive. If that distinction is new to you, read when to use the "use client" directive in Next.js before you start converting pages.
Step 1: Prepare the Project
Start by upgrading Next.js, React, and the ESLint config. The App Router has been stable since Next.js 13.4, but moving straight to the current major version saves you a second migration later.
npm install next@latest react@latest react-dom@latest
npm install -D eslint-config-next@latest @types/react@latest @types/react-dom@latest
Then run the official upgrade codemod, which handles most of the mechanical breaking changes between major versions:
npx @next/codemod@canary upgrade latest
A few version-specific changes affect App Router code you are about to write, so it is worth knowing them up front:
- Next.js 15 made
params,searchParams,cookies(),headers(), anddraftMode()asynchronous. You mustawaitthem. Code samples written for Next.js 13 or 14 that readparams.slugdirectly will not type-check. - Next.js 15 also stopped caching
fetchrequests and GET Route Handlers by default. If you want static behavior, you opt in explicitly. - Next.js 16 renamed
middleware.tstoproxy.ts. Middleware still runs for both routers, so if you have one, rename it during the upgrade rather than during the migration.
Commit the upgrade on its own before touching any routes. If something breaks later, you will know whether the cause was the version bump or the migration.
For more on keeping a project current, see how to update to the latest version of Next.js.
Step 2: Create the Root Layout
Create an app directory next to pages (or inside src/ if your project uses one). The first file it needs is a root layout. The root layout is required, must render the html and body tags itself, and replaces both _app.tsx and _document.tsx for every route under app.
Here is a typical Pages Router setup:
// pages/_app.tsx
import type { AppProps } from "next/app";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import "@/styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return (
<ThemeProvider>
<Header />
<Component {...pageProps} />
</ThemeProvider>
);
}
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}
The App Router equivalent merges both into one file:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Providers } from "./providers";
import { Header } from "@/components/header";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export const metadata: Metadata = {
title: {
default: "Acme",
template: "%s | Acme",
},
description: "Tools for modern teams.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body className="antialiased">
<Providers>
<Header />
{children}
</Providers>
</body>
</html>
);
}
Context providers such as a theme provider use React context, which only works in Client Components. Move them into a small wrapper file marked with "use client":
// app/providers.tsx
"use client";
import { ThemeProvider } from "@/components/theme-provider";
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
The layout stays a Server Component, and only the provider ships to the browser. The children passed through it can still be Server Components.
Keep _app.tsx and _document.tsx until the migration is finished. Styles and providers in app/layout.tsx do not apply to anything in pages, and vice versa. Both routers need their own shell until the last page moves. If you want a refresher on what these files did, see the role of the _app.js file and the significance of the _document.js file.
Replacing the getLayout Pattern
If you used per-page layouts with Page.getLayout, you can delete that pattern entirely. Nested layouts are built in. A layout.tsx inside a folder wraps every route below it and persists across navigations between those routes:
// app/dashboard/layout.tsx
import { Sidebar } from "@/components/sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[240px_1fr] gap-6">
<Sidebar />
<main>{children}</main>
</div>
);
}
Because layouts do not re-render on navigation, state inside the sidebar (an expanded menu, a scroll position) survives when the user moves between dashboard pages, which the getLayout pattern could only approximate.
Step 3: Replace next/head with the Metadata API
next/head does not work in the app directory. Instead, export a metadata object for static values or a generateMetadata function for values that depend on the route:
// pages/about.tsx (before)
import Head from "next/head";
export default function About() {
return (
<>
<Head>
<title>About | Acme</title>
<meta name="description" content="Who we are and what we build." />
</Head>
<h1>About</h1>
</>
);
}
// app/about/page.tsx (after)
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description: "Who we are and what we build.",
};
export default function AboutPage() {
return <h1>About</h1>;
}
With the title.template set in the root layout, "About" renders as About | Acme. Metadata exports only work in Server Components, which is one more reason to keep page.tsx files on the server. Open Graph images, canonical URLs, robots rules, and dynamic titles are covered in detail in how to use the Metadata API for dynamic SEO tags.
Step 4: Migrate a Page
Pick a simple, low-traffic page for your first move, such as /about or /terms. Once it works, move to pages with data fetching.
When a route exists in both pages and app, the build fails with a conflict error, so the migration of a single route is always: create the new file in app, delete the old file in pages, and test.
The lowest-risk way to move a page with interactivity is the two-file pattern recommended in the official guide:
- Move the existing page component, unchanged, into a Client Component file.
- Create a new
page.tsxServer Component that fetches the data and renders the Client Component.
// app/products/products-view.tsx
"use client";
import { useState } from "react";
type Product = { id: string; name: string; price: number };
export function ProductsView({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const visible = products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<section>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products"
/>
<ul>
{visible.map((p) => (
<li key={p.id}>
{p.name} — ${p.price.toFixed(2)}
</li>
))}
</ul>
</section>
);
}
// app/products/page.tsx
import type { Metadata } from "next";
import { ProductsView } from "./products-view";
export const metadata: Metadata = { title: "Products" };
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 300 },
});
if (!res.ok) throw new Error("Failed to load products");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return <ProductsView products={products} />;
}
This keeps behavior almost identical to the Pages Router version: the page is still server-rendered, then hydrated. Later, you can shrink the Client Component down to just the search input and render the list on the server, which cuts JavaScript. Do that as a separate refactor, not as part of the move.
Step 5: Convert Data Fetching
The App Router has no special data fetching exports. A page is an async function, and it fetches whatever it needs while it renders. The caching behavior that used to be implied by the function name (getStaticProps vs getServerSideProps) is now set on each request.
getServerSideProps
// pages/dashboard.tsx (before)
import type { GetServerSideProps } from "next";
type Props = { projects: { id: string; name: string }[]; theme: string };
export const getServerSideProps: GetServerSideProps<Props> = async ({
req,
}) => {
const theme = req.cookies.theme ?? "light";
const res = await fetch("https://api.example.com/projects");
return { props: { projects: await res.json(), theme } };
};
export default function Dashboard({ projects, theme }: Props) {
return (
<ul data-theme={theme}>
{projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
// app/dashboard/page.tsx (after)
import { cookies } from "next/headers";
type Project = { id: string; name: string };
export default async function DashboardPage() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
const res = await fetch("https://api.example.com/projects", {
cache: "no-store",
});
const projects: Project[] = await res.json();
return (
<ul data-theme={theme}>
{projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Reading cookies() or headers() makes the route dynamic, which matches the per-request behavior of getServerSideProps. The req object no longer exists; cookies() and headers() from next/headers are its replacements, and both must be awaited.
getStaticProps and Revalidation
// app/blog/page.tsx
type Post = { slug: string; title: string };
export default async function BlogPage() {
const res = await fetch("https://cms.example.com/posts", {
cache: "force-cache",
next: { revalidate: 3600, tags: ["posts"] },
});
const posts: Post[] = await res.json();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
);
}
next: { revalidate: 3600 } is the equivalent of returning revalidate: 3600 from getStaticProps. The tags option lets you invalidate this data on demand from a webhook with revalidateTag, which replaces res.revalidate() from the Pages Router.
If you are not using fetch, for example when querying a database through an ORM, you can set the revalidation interval for the whole route with segment config:
// app/blog/page.tsx
export const revalidate = 3600;
getStaticPaths and fallback
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getAllPosts, getPost } from "@/lib/posts";
export const dynamicParams = false; // like fallback: false
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
Notes on the conversion:
generateStaticParamsreturns plain objects, such as{ slug: "hello-world" }, not{ params: { slug } }.dynamicParams = falsereturns a 404 for slugs that were not generated, likefallback: false. The default,true, renders unknown slugs on demand and caches them, which covers bothfallback: trueandfallback: "blocking".notFound()replaces returning{ notFound: true }.PageProps<"/blog/[slug]">is a globally available helper type in recent Next.js versions that typesparamsandsearchParamsfrom the route string. On older versions, type the props by hand as{ params: Promise<{ slug: string }> }.
Step 6: Replace Routing Hooks
The useRouter hook from next/router does not work in the app directory. Its responsibilities are split across four hooks from next/navigation, all of which require a Client Component:
next/router usage | next/navigation replacement |
|---|---|
router.push, router.replace, router.back | useRouter() (same methods) |
router.pathname | usePathname() |
router.query (search part) | useSearchParams() |
router.query (dynamic segments) | useParams() |
router.isReady | Not needed |
router.events | Watch usePathname and useSearchParams in an effect |
// app/components/category-filter.tsx
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function CategoryFilter({ categories }: { categories: string[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const active = searchParams.get("category") ?? "all";
function select(category: string) {
const params = new URLSearchParams(searchParams.toString());
if (category === "all") params.delete("category");
else params.set("category", category);
router.push(`${pathname}?${params.toString()}`);
}
return (
<div role="group" aria-label="Filter by category">
{["all", ...categories].map((c) => (
<button key={c} aria-pressed={c === active} onClick={() => select(c)}>
{c}
</button>
))}
</div>
);
}
In a Server Component page, you do not need hooks at all. Read the same values from props:
// app/shop/page.tsx
export default async function ShopPage({ searchParams }: PageProps<"/shop">) {
const { category } = await searchParams;
// Filter on the server using `category`
return <h1>Shop {typeof category === "string" ? `: ${category}` : ""}</h1>;
}
A component that uses useSearchParams should be wrapped in a Suspense boundary when it appears on a statically rendered route; otherwise the build warns that the whole page will fall back to client rendering up to the nearest boundary.
If you have shared components that must work in both routers during the transition, import useRouter from next/compat/router. It returns the Pages Router instance in pages and null in app, so you can branch on it until the migration is done.
Step 7: Move API Routes to Route Handlers
pages/api keeps working without changes, so this step can wait until the end. When you do move them, each endpoint becomes a route.ts file that exports one function per HTTP method, using the standard Web Request and Response APIs:
// pages/api/subscribe.ts (before)
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required" });
await addSubscriber(email);
res.status(201).json({ ok: true });
}
// app/api/subscribe/route.ts (after)
import { NextResponse } from "next/server";
import { addSubscriber } from "@/lib/newsletter";
export async function POST(request: Request) {
const { email } = await request.json();
if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
await addSubscriber(email);
return NextResponse.json({ ok: true }, { status: 201 });
}
Requests with methods you did not export get a 405 Method Not Allowed automatically. A route.ts and a page.tsx cannot live in the same folder, which is why API endpoints usually stay under app/api/.
Before migrating an endpoint, ask whether it still needs to exist. Many Pages Router API routes only existed so a client component could load data or submit a form. In the App Router, a Server Component can fetch the data directly, and a Server Action can handle the form, with no public endpoint at all. Keep Route Handlers for things external clients call: webhooks, mobile apps, and third-party integrations.
Step 8: Error and Not-Found Pages
Replace pages/404.tsx with app/not-found.tsx and pages/_error.tsx with error.tsx files at whatever level you want errors caught:
// app/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.digest ? `Reference: ${error.digest}` : null}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>Page not found</h2>
<Link href="/">Back to home</Link>
</div>
);
}
error.tsx must be a Client Component because it receives the reset function. Errors thrown in the root layout itself are caught by app/global-error.tsx, which must render its own html and body tags.
Step 9: Finish and Clean Up
Once the last route has moved:
- Delete
pages/_app.tsx,pages/_document.tsx,pages/404.tsx, andpages/_error.tsx. - Delete the empty
pagesdirectory. - Remove
next/router,next/head, andnext/compat/routerimports; a project-wide search is enough. - Remove
i18nfromnext.configif you used the Pages Router's built-in internationalization; the App Router handles locales through a[lang]segment and the proxy instead. - Run
npm run buildand check the route summary to confirm each page is static or dynamic as you intended.
Navigations between a page in pages and a page in app are full page loads, not client-side transitions, and next/link does not prefetch across the two routers. That is fine during the migration, but it is a reason not to leave a project half-migrated for months. Migrating routes that link to each other heavily in the same batch, such as a blog index and its posts, keeps the user experience smooth in the meantime.
Common Problems and Fixes
- "Conflicting app and page file was found." The same URL exists in both
pagesandapp. Delete thepagesversion once theappversion is ready. - "You're importing a component that needs useState." A file in
appuses hooks without"use client". Add the directive at the top of the file, or move the interactive part into its own Client Component. - Global CSS disappears on migrated pages. Styles imported in
_app.tsxdo not apply toapp. Importglobals.cssinapp/layout.tsxtoo. params.slugis undefined or a type error. Since Next.js 15,paramsis a Promise. Useconst { slug } = await params.- Data that was static is now fetched on every request.
fetchis not cached by default since Next.js 15. Addcache: "force-cache"ornext: { revalidate }, or exportrevalidatefrom the page. - Context is undefined in a Server Component. Server Components cannot read React context. Pass data as props, or read it inside a Client Component below the provider.
- Third-party UI library breaks on import. Many libraries use hooks but do not declare
"use client". Wrap them in a file that re-exports them with the directive.
Pages to App Router Migration FAQ
No. The pages and app directories are designed to run together in the same project. You can move one route at a time, deploy after each step, and keep the remaining routes on the Pages Router as long as you need. The only rule is that a given URL can exist in only one of the two directories.
No. The Pages Router is still supported and receives bug fixes and security updates. However, new features such as Server Components, Server Actions, streaming, and Cache Components are only available in the App Router, so most new development and documentation focus there.
An async Server Component that fetches its own data. Use fetch with the no-store cache option, or read cookies or headers, to make the route render on every request. The data is fetched during rendering, so there is no separate props function and no need to serialize data through pageProps.
Yes. API routes in pages/api keep working unchanged even after every page has moved to the app directory. You can migrate them to Route Handlers later, or replace some of them with Server Actions when they only existed to support your own forms.
Navigating between a Pages Router route and an App Router route is a full page load rather than a client-side transition, and links are not prefetched across the two routers. It goes away once both pages live in the same router, so migrate pages that link to each other heavily together.
Only while some routes remain in the pages directory, because the root layout in app does not apply to them. Once the last page has moved, delete both files along with the empty pages directory.
Conclusion
Migrating from the Pages Router to the App Router is less of a rewrite than it first looks. Each Pages Router concept has a clear replacement: the root layout absorbs _app and _document, the Metadata API replaces next/head, async Server Components replace the data fetching exports, generateStaticParams and dynamicParams replace getStaticPaths and fallback, and Route Handlers replace API routes.
The safest approach is incremental. Upgrade first, create the root layout, move a simple page, then work through the data-heavy routes using the two-file pattern so behavior stays the same. Only after everything runs on the App Router should you start optimizing, moving interactivity into smaller Client Components and letting more of the tree render on the server. That second pass is where most of the performance gains come from.
Here are some useful references for the migration:
- Next.js Docs: Migrating from Pages to the App Router — the official step-by-step migration guide.
- Next.js Docs: Layouts and Pages — how routes, layouts, and dynamic segments work in the app directory.
- Next.js Docs: Fetching Data — data fetching patterns in Server and Client Components.
- Next.js Docs: Codemods — automated transforms for upgrades and breaking changes.
- React Docs: Server Components — the React feature the App Router is built on.


