MervCodes

Tech Reviews From A Programmer

Next.js App Router Migration: Pages to App Router

1 min read

Next.js App Router Migration: Pages to App Router

The App Router has been the recommended way to build Next.js applications for a while now, but plenty of production codebases still run on the Pages Router. If you maintain one of them, you have probably felt the pull: React Server Components, nested layouts, streaming, and a cleaner data-fetching story are all hard to ignore. The good news is that Next.js was designed to let both routers coexist in the same project, so you do not have to migrate everything in a single weekend. This guide walks through a practical, incremental migration strategy that keeps your app shipping the whole way through.

Why Migrate at All?

Before you spend engineering hours on a migration, it helps to be honest about the payoff. The App Router is not just a folder rename — it changes the mental model.

  • Server Components by default. Components render on the server unless you opt into client behavior with "use client". This shrinks your JavaScript bundle and moves data fetching closer to your data.
  • Nested layouts that persist. Layouts no longer re-render on navigation, so shared shells (sidebars, headers) keep their state across route changes.
  • Streaming and Suspense. You can stream HTML to the browser and show meaningful loading UI per route segment with loading.tsx.
  • Colocated data fetching. No more getServerSideProps boilerplate at the page level — you await your data directly inside a Server Component.

If your app is mostly static marketing pages, the upside is smaller. If it is a data-heavy dashboard, the wins are substantial.

The Two Routers Can Coexist

The single most important fact for a low-risk migration: pages/ and app/ can live in the same project simultaneously. Next.js resolves conflicts by giving app/ priority, but as long as two routes do not resolve to the same URL, both directories serve traffic.

This unlocks the only sane strategy for a large codebase: migrate route by route. Move a leaf page into app/, verify it in production, then move the next one. You never have a big-bang cutover, and you can roll back a single route by deleting its app/ entry.

A word of caution: do not have pages/about.tsx and app/about/page.tsx at the same time. The build will warn you, and the app/ version wins, which can cause confusing "why isn't my change showing up" moments.

Step 1: Update Your Project Structure

Create the app/ directory at the same level as pages/. Your first task is establishing the root layout, which replaces _app.tsx and _document.tsx.

// app/layout.tsx
export const metadata = {
  title: "My App",
  description: "Migrated to the App Router",
};

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

Notice the <html> and <body> tags now live in your layout — that responsibility moved out of _document.tsx. Global CSS imports that lived in _app.tsx move to this root layout too.

Step 2: Translate Data Fetching

This is where most of the real work lives. The App Router removes getServerSideProps, getStaticProps, and getInitialProps. Instead, Server Components fetch data inline.

Old Pages Router pattern:

// pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
  const post = await fetchPost(params.id);
  return { props: { post } };
}

export default function Post({ post }) {
  return <article>{post.title}</article>;
}

New App Router pattern:

// app/posts/[id]/page.tsx
export default async function Post({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await fetchPost(id);
  return <article>{post.title}</article>;
}

The component itself became async. Note that in recent Next.js versions params and searchParams are Promises you await — a common gotcha when following older tutorials.

For getStaticProps with getStaticPaths, the equivalent is generateStaticParams:

export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map((post) => ({ id: post.id }));
}

Caching behavior is controlled through the fetch options and route segment config (export const revalidate = 60) rather than which lifecycle function you exported.

Step 3: Sort Out Client vs. Server Components

Server Components cannot use hooks, browser APIs, or event handlers. When you move a page that uses useState, useEffect, onClick, or a context provider, you will hit build errors.

The fix is the "use client" directive at the top of the file. But do not slap it on everything — that defeats the purpose. Instead, push the client boundary down the tree. Keep the page a Server Component that fetches data, and extract the interactive bits into small client components.

// app/dashboard/page.tsx  (Server Component)
import { LikeButton } from "./like-button";

export default async function Dashboard() {
  const stats = await getStats();
  return (
    <>
      <h1>{stats.title}</h1>
      <LikeButton initialCount={stats.likes} />
    </>
  );
}
// app/dashboard/like-button.tsx
"use client";
import { useState } from "react";

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Step 4: Update Routing APIs

Navigation hooks changed packages. Anything importing from next/router must move to next/navigation.

  • useRouter() from next/routeruseRouter() from next/navigation (the API surface differs — router.push stays, but router.query is gone).
  • Reading query params → useSearchParams().
  • Reading the current path → usePathname().
  • Dynamic route params in client components → useParams().

All of these are client-only hooks, so the file needs "use client". If you only need params on the server, read them from the params prop instead.

Step 5: Migrate Special Files

The Pages Router's magic files have App Router equivalents:

  • _app.tsx / _document.tsxapp/layout.tsx
  • pages/404.tsxapp/not-found.tsx
  • pages/_error.tsxapp/error.tsx (must be a client component)
  • API routes in pages/api/*app/*/route.ts using the new Route Handlers with GET, POST, etc. exports

You can migrate API routes last, or leave them in pages/api/ indefinitely — they keep working.

Step 6: Verify and Measure

After each migrated route, check three things: the page renders identically, the JavaScript bundle did not balloon (run next build and read the output), and your SEO metadata still emits correctly. The Metadata API replaces next/head, so any <Head> usage needs to become an exported metadata object or a generateMetadata function.

Common Pitfalls to Avoid

  • Forgetting to await params. In current Next.js, params is a Promise. Destructuring it synchronously silently breaks.
  • Over-using "use client". Every directive at a high level pulls its entire subtree into the client bundle.
  • Assuming hydration parity. Server Components never hydrate; if you relied on useEffect for something, rethink whether it belongs on the server.
  • Third-party libraries without "use client". Many older libs use hooks internally. Wrap them in a thin client component.

FAQ

Do I have to migrate my whole app at once? No. pages/ and app/ coexist. Migrate one route at a time and ship continuously. This is the officially supported and recommended approach for large codebases.

Will my API routes break? No. Route Handlers in app/ and API Routes in pages/api/ work side by side. Migrate them on your own schedule, or never.

What happens if a route exists in both directories? The app/ version wins and you get a build-time warning. Never keep duplicate routes intentionally — delete the pages/ version once the app/ one is verified.

Is getServerSideProps really gone? In the App Router, yes. You fetch data directly inside async Server Components and control caching with fetch options and route segment config like revalidate.

How do I handle global state providers like Redux or React Query? Create a client component that renders the provider ("use client" at the top) and place it inside your root layout, wrapping children. The provider is a client boundary; the layout itself stays a Server Component.

Does migrating improve performance automatically? Often, but not magically. The bundle savings come from keeping components on the server. If you mark everything "use client", you get the App Router's overhead without its benefits.

Wrapping Up

Migrating from the Pages Router to the App Router is less a rewrite and more a gradual translation. Start with the root layout, move leaf pages one at a time, push client boundaries as deep as they will go, and lean on the fact that both routers run together. Do it incrementally, measure each step, and you will land on a faster, more maintainable application without a single scary deployment.

Sources

Related Articles