Skip to content
lazy devs
4 min readLazy Devs

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:

  1. Move the root layout and global providers first.
  2. Port low-traffic, low-risk routes (settings, an about page) to learn the patterns.
  3. Then move the high-value data-heavy routes where Server Components pay off.
  4. Leave anything touching getServerSideProps with 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:

  • useRouter from next/router becomes useRouter from next/navigation, and router.query is gone. Read params from the params and searchParams props the page receives, or use the useParams and useSearchParams hooks in Client Components.
  • next/head is replaced by the metadata export (static) or generateMetadata (dynamic). No more <Head> component in App Router pages.
  • API routes in pages/api/ move to app/api/route.ts files that export named functions like GET and POST using the Web Request and Response objects.

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.

Related service

Next.js Development Services

Production Next.js apps and sites, tuned for speed and search.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com