Migrating a Next.js app to the App Router
How to move a Pages Router app to the App Router without a big-bang rewrite: run both routers side by side, port routes by traffic, and avoid the common traps.

The good news about migrating a Next.js app to the App Router is that you do not have to do it all at once. The pages/ and app/ directories coexist in the same project, so you can move one route at a time and ship as you go. The bad news is that a few habits from the Pages Router will fight you, and the order in which you migrate matters more than people expect.
Why bother at all
If your Pages Router app is fast and you are happy, you can stay. Next.js still supports pages/, and a forced rewrite is rarely worth the disruption.
The reasons we actually move clients over are concrete: Server Components let you fetch data on the server without shipping that code or those queries to the browser, nested layouts stop the re-render-the-whole-tree problem, and streaming with Suspense lets you send the shell before the slow data is ready. If your app has heavy data-fetching pages, a deep layout hierarchy, or a fat client bundle, those are the wins you are buying.
Run both routers at once
This is the part that makes incremental migration possible. When both directories exist, the App Router takes precedence for any path it defines, and the Pages Router handles the rest. So you can leave ninety percent of your app in pages/ and carve off one route into app/.
There is exactly one rule that bites everyone: a single path cannot be defined in both routers. You cannot have pages/dashboard.tsx and app/dashboard/page.tsx at the same time. Next.js will error at build. Delete the old file in the same commit that adds the new one.
Our usual order of operations:
- Move the root layout and global providers first.
- Port low-traffic, low-risk routes (settings, an about page) to learn the patterns.
- Then move the high-value data-heavy routes where Server Components pay off.
- Leave anything touching
getServerSidePropswith complex auth for last.
The root layout replaces _app and _document
In the Pages Router, _app.tsx and _document.tsx wrap everything. In the App Router, that job belongs to app/layout.tsx, and it must render the html and body tags itself. This is the first file to create, and it is where your global CSS and context providers live.
Providers like a theme context or a query client need to be Client Components, so split them out:
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}// app/layout.tsx
import "./globals.css";
import { Providers } from "./providers";
export const metadata = { title: "Dashboard" };
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Note that the layout itself stays a Server Component. Only the provider wrapper opts into the client. Keep that boundary as small as you can.
Data fetching: getServerSideProps becomes an async component
This is the change that does the most work. A page that used getServerSideProps to load data and pass it as props collapses into an async Server Component that fetches inline.
Here is a typical before and after. The Pages Router version:
// pages/orders.tsx (before)
export async function getServerSideProps() {
const orders = await db.order.findMany({ orderBy: { createdAt: "desc" } });
return { props: { orders } };
}
export default function Orders({ orders }) {
return <OrderList orders={orders} />;
}The App Router version reads the database directly inside the component. There is no props plumbing, and the query never reaches the browser:
// app/orders/page.tsx (after)
import { db } from "@/lib/db";
import { OrderList } from "@/components/order-list";
export default async function OrdersPage() {
const orders = await db.order.findMany({ orderBy: { createdAt: "desc" } });
return <OrderList orders={orders} />;
}One thing to watch: by default the App Router will try to cache and statically render where it can. If a route must run per request (it reads cookies, headers, or live data), reaching for those per-request APIs marks it dynamic automatically. If you need to force it, add export const dynamic = "force-dynamic" to the page.
Hooks and APIs that changed names
A lot of migration time is spent on small renames. The ones that come up on every project:
useRouterfromnext/routerbecomesuseRouterfromnext/navigation, androuter.queryis gone. Read params from theparamsandsearchParamsprops the page receives, or use theuseParamsanduseSearchParamshooks in Client Components.next/headis replaced by themetadataexport (static) orgenerateMetadata(dynamic). No more<Head>component in App Router pages.- API routes in
pages/api/move toapp/api/route.tsfiles that export named functions likeGETandPOSTusing the WebRequestandResponseobjects.
That last one trips people up because the signature is different:
// app/api/orders/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET() {
const orders = await db.order.findMany();
return NextResponse.json(orders);
}The "use client" boundary is the real work
The mechanical renames are easy. The judgment call is deciding which components stay on the server and which need the browser. Anything using useState, useEffect, event handlers, or browser-only APIs needs "use client" at the top.
The mistake we see most often is slapping "use client" on a whole page because one button needs an onClick. That drags the entire subtree into the client bundle and throws away the main benefit you came for. Push the directive down to the smallest leaf that actually needs interactivity, and let everything above it stay a Server Component that fetches data and renders HTML.
A practical tell during migration: if a component imports both a database client and a useState, it is doing two jobs and needs to be split. The data part stays on the server, the interactive part becomes a small client island that receives the data as props.
Verify with the build output
After each route moves, run next build and read the report. Each route is marked static or dynamic, and you can see which ones became dynamic by accident. If a page you expected to prerender shows up dynamic, something on it reached for a per-request API you did not intend. Catching that during migration is far cheaper than catching it as a latency complaint later.
Takeaway
Treat this as a series of small, shippable commits, not a rewrite. Stand up the root layout, move one quiet route to learn the patterns, then work through your data-heavy pages where Server Components actually save you bundle size and round trips. Keep the client boundary tiny, delete the old pages/ file in the same commit you add the new one, and read the build report after every move. Done this way, migrating to the App Router is a Tuesday, not a quarter.
If you want a second pair of eyes on the boundary decisions, that is the kind of thing we are happy to look at.