Type something to search...
Migrating a Next.js Project from the Pages Router to the App Router, Step by Step

Migrating a Next.js Project from the Pages Router to the App Router, Step by Step

Moving a real project from the Pages Router to the App Router is less scary than it looks, as long as you don't try to do it all at once. The app and pages directories can run side by side in the same project, so you can move one route, ship it, and move the next one when you're ready.

This guide walks through a migration in the order I'd actually do it on a typical project: upgrade first, then create the root layout, then move providers and metadata, then convert pages one by one (static, dynamic, server-rendered), then API routes, error pages, and routing hooks. Every step shows the Pages Router code you're starting from and the App Router code you end up with, using Next.js 16.

The running example is a small blog with a home page, post pages, an account page that reads a cookie, an API route, and a custom 404.

Step 0: Upgrade Next.js First

Don't combine a major version upgrade with a router migration. Get the Pages Router app running on the latest Next.js before you touch app.

npx @next/codemod@canary upgrade latest

The upgrade codemod bumps next, react, and react-dom, and handles several mechanical changes. A few Next.js 16 changes are worth checking manually:

  • Node.js 20.9 or later is required.
  • Turbopack is the default bundler for next dev and next build. If you have a custom webpack config, either migrate it or build with next build --webpack for now.
  • next lint has been removed. Run ESLint directly (for example eslint .) and update your package.json scripts.
  • middleware.ts is now proxy.ts, and the exported function is named proxy.

Run the app, click through it, and deploy. Now you have a clean baseline.

Step 1: Create the app Directory and Root Layout

Create an app folder next to pages (or inside src/ if that's where your pages folder lives). The first file it needs is a root layout, which replaces both _app.tsx and _document.tsx for routes inside app.

Here's what a typical starting point looks like:

// pages/_app.tsx
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
import { Header } from "@/components/header";
import "@/styles/globals.css";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider attribute="class">
      <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>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

The App Router equivalent is a single file that renders the html and body tags itself:

// app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "./providers";
import { Header } from "@/components/header";
import "@/styles/globals.css";

export const metadata: Metadata = {
  title: {
    default: "TideWave Blog",
    template: "%s | TideWave Blog",
  },
  description: "Articles about web development.",
};

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

A few things changed here:

  • The lang attribute moves from _document to the html tag in the layout.
  • Global CSS is imported in the root layout. In app, you can import global CSS from any layout or component, not only from one special file.
  • There's no Head, Main, or NextScript. Next.js injects scripts and head content itself.
  • suppressHydrationWarning is there because next-themes sets a class on html before React hydrates.

Keep _app.tsx and _document.tsx in place for now. They still serve every route in pages, and the root layout doesn't apply to those routes.

Step 2: Move Context Providers into a Client Component

The root layout is a Server Component, and Server Components can't use React context. Any provider that relies on context or state (themes, auth sessions, query clients, toast libraries) needs to move into a Client Component:

// app/providers.tsx
"use client";

import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: React.ReactNode }) {
  return <ThemeProvider attribute="class">{children}</ThemeProvider>;
}

The "use client" directive marks the boundary. children passed through a Client Component can still be Server Components, so wrapping your whole app in Providers doesn't turn every page into client code.

If Header uses hooks like usePathname or useState, it needs "use client" at the top too. If it only renders links and static markup, leave it as a Server Component.

Step 3: Replace next/head with the Metadata API

In the Pages Router, each page sets its title and meta tags with next/head:

// pages/about.tsx
import Head from "next/head";

export default function About() {
  return (
    <>
      <Head>
        <title>About | TideWave Blog</title>
        <meta name="description" content="Who we are." />
      </Head>
      <h1>About</h1>
    </>
  );
}

In app, next/head isn't supported. Export a metadata object instead:

// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "Who we are.",
};

export default function AboutPage() {
  return <h1>About</h1>;
}

Because the root layout defines a title.template, this page's title renders as "About | TideWave Blog". For pages whose metadata depends on data, use generateMetadata, which you'll see in step 5.

Once app/about/page.tsx exists, delete pages/about.tsx. A path can't be defined in both directories; Next.js reports a conflict if it is.

Step 4: Migrate a Static Page with getStaticProps

The home page lists posts and is generated at build time:

// pages/index.tsx
import type { GetStaticProps } from "next";
import Link from "next/link";
import { getAllPosts, type Post } from "@/lib/posts";

export const getStaticProps: GetStaticProps<{ posts: Post[] }> = async () => {
  const posts = await getAllPosts();
  return { props: { posts }, revalidate: 3600 };
};

export default function Home({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

In app, the data loading moves into the component itself:

// app/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";

export const revalidate = 3600;

export default async function HomePage() {
  const posts = await getAllPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

The component is now async and awaits its data directly. getStaticProps disappears, and its revalidate option becomes a route segment config export with the same meaning: regenerate this page at most once an hour. Because the page doesn't use cookies, headers, or searchParams, it's prerendered at build time, just as before.

If getAllPosts calls an API with fetch, you can also set caching per request with fetch(url, { next: { revalidate: 3600 } }). If it reads from a database or the file system, the segment-level revalidate export is the simplest equivalent.

One caveat: if you've turned on cacheComponents in next.config.ts, the model is different: segment config like revalidate and dynamicParams isn't available, and you mark cached functions with "use cache" and cacheLife("hours") instead. Migrate to the App Router first with the default model, then adopt Cache Components as a separate step.

Dealing with Client-Side Code in the Page

If the old page component uses useState, useEffect, or browser APIs, you can't paste it into an async Server Component. The low-effort path is to keep the existing component as a Client Component and render it from a new server page:

// app/home-client.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import type { Post } from "@/lib/posts";

export function HomeClient({ posts }: { posts: Post[] }) {
  const [query, setQuery] = useState("");
  const visible = posts.filter((p) =>
    p.title.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {visible.map((post) => (
          <li key={post.slug}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}
// app/page.tsx
import { getAllPosts } from "@/lib/posts";
import { HomeClient } from "./home-client";

export const revalidate = 3600;

export default async function HomePage() {
  const posts = await getAllPosts();
  return <HomeClient posts={posts} />;
}

This is the closest equivalent to how the Pages Router worked: the server loads data and passes it as props to an interactive component. Later, you can push "use client" further down so only the search input is client code.

Step 5: Migrate Dynamic Routes with getStaticPaths

The post page uses both getStaticPaths and getStaticProps:

// pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import Head from "next/head";
import { getAllPosts, getPost, type Post } from "@/lib/posts";

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getAllPosts();
  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps<{ post: Post }> = async ({
  params,
}) => {
  const post = await getPost(params!.slug as string);
  if (!post) return { notFound: true };
  return { props: { post } };
};

export default function PostPage({ post }: { post: Post }) {
  return (
    <>
      <Head>
        <title>{post.title}</title>
      </Head>
      <article dangerouslySetInnerHTML={{ __html: post.html }} />
    </>
  );
}

The App Router version moves to app/blog/[slug]/page.tsx:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllPosts, getPost } from "@/lib/posts";

export const dynamicParams = false;

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

export async function generateMetadata({
  params,
}: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post?.title ?? "Post not found" };
}

export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}

Here's how the pieces map:

Pages RouterApp Router
getStaticPaths returning pathsgenerateStaticParams returning [{ slug }]
fallback: falseexport const dynamicParams = false
fallback: true or "blocking"dynamicParams = true (the default)
return { notFound: true }notFound() from next/navigation
params in the context argumentparams prop, a promise you await
next/head titlegenerateMetadata

generateStaticParams returns plain objects ({ slug: "hello" }) rather than { params: { ... } } wrappers. PageProps<"/blog/[slug]"> is a global type helper that Next.js generates from your folder structure during next dev, next build, or next typegen, so params is typed as Promise<{ slug: string }> without you writing the type yourself.

getPost is called in both generateMetadata and the page. If it uses fetch, the two calls are deduplicated automatically. If it hits a database, wrap it with React's cache so the query runs once per request:

// lib/posts.ts
import { cache } from "react";
import { db } from "@/lib/db";

export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } });
});

Step 6: Migrate Server-Rendered Pages with getServerSideProps

The account page reads a session cookie and redirects anonymous users:

// pages/account.tsx
import type { GetServerSideProps } from "next";
import { getUserFromSession, type User } from "@/lib/auth";

export const getServerSideProps: GetServerSideProps<{ user: User }> = async ({
  req,
}) => {
  const user = await getUserFromSession(req.cookies.session);
  if (!user) {
    return { redirect: { destination: "/login", permanent: false } };
  }
  return { props: { user } };
};

export default function Account({ user }: { user: User }) {
  return <h1>Hello, {user.name}</h1>;
}

In app, there's no req object. Use the async cookies() function and redirect():

// app/account/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getUserFromSession } from "@/lib/auth";

export default async function AccountPage() {
  const cookieStore = await cookies();
  const user = await getUserFromSession(cookieStore.get("session")?.value);

  if (!user) redirect("/login");

  return <h1>Hello, {user.name}</h1>;
}

Calling cookies() makes the route render per request, which is what getServerSideProps did. You don't need to opt in separately. The same applies to headers() and to reading searchParams in a page.

redirect() throws internally, so code after it doesn't run, and TypeScript narrows user to non-null on the next line. For more on auth patterns in the App Router, see managing authentication in Next.js.

Step 7: Move API Routes to Route Handlers

API routes in pages/api keep working without changes, so this step is optional. When you do move them, the handler style changes from Node-style req/res to Web Request/Response:

// pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getAllPosts } from "@/lib/posts";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (req.method !== "GET") return res.status(405).end();
  const posts = await getAllPosts();
  res.status(200).json(posts);
}
// app/api/posts/route.ts
import { getAllPosts } from "@/lib/posts";

export async function GET() {
  const posts = await getAllPosts();
  return Response.json(posts);
}

Each HTTP method is a named export. Unsupported methods automatically get a 405 response. For dynamic API routes, the second argument holds params, also a promise:

// app/api/posts/[slug]/route.ts
import { getPost } from "@/lib/posts";

export async function GET(
  _request: Request,
  { params }: RouteContext<"/api/posts/[slug]">,
) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  return Response.json(post);
}

Before moving an API route, check who calls it. If it only exists so the browser can fetch data for a page, you may not need it at all: the page can now fetch that data on the server. If it's used for form submissions, consider a Server Action instead.

Step 8: Replace 404.tsx and _error.tsx

The Pages Router uses pages/404.tsx and pages/_error.tsx. The App Router splits these into per-segment files:

// app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <main>
      <h1>Page not found</h1>
      <Link href="/">Back to the homepage</Link>
    </main>
  );
}
// app/error.tsx
"use client";

export default function Error({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  return (
    <main>
      <h1>Something went wrong</h1>
      <p>{error.digest ? `Reference: ${error.digest}` : null}</p>
      <button onClick={() => retry()}>Try again</button>
    </main>
  );
}

app/not-found.tsx handles unmatched URLs and notFound() calls. error.tsx must be a Client Component. Its retry prop (stable since Next.js 16.3) re-fetches and re-renders the failed segment; older examples use reset, which only clears the error state without re-fetching. You can add more specific versions in nested folders later, for example a different error UI for /dashboard.

Step 9: Update Routing Hooks

Any component used by app routes must import routing hooks from next/navigation instead of next/router. The API is split into smaller hooks:

next/router (Pages)next/navigation (App)
router.push, router.replace, router.backuseRouter() with the same methods
router.pathname, router.asPathusePathname()
router.query (search params)useSearchParams()
router.query (dynamic params)useParams()
router.eventsWatch usePathname() and useSearchParams() in an effect
router.isReadyNot needed

A typical active-link component changes like this:

// components/nav-link.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

export function NavLink({
  href,
  children,
}: {
  href: string;
  children: React.ReactNode;
}) {
  const pathname = usePathname();
  const isActive = pathname === href || pathname.startsWith(`${href}/`);

  return (
    <Link href={href} aria-current={isActive ? "page" : undefined}>
      {children}
    </Link>
  );
}

While both routers are in use, a component rendered by both pages and app routes can't import from next/navigation and still work in pages in every case. Use useRouter from next/compat/router in shared components during the transition. It returns null when rendered in app, so you can branch on that.

Step 10: Convert getLayout to Nested Layouts

If you used the getLayout pattern for section layouts, each page attached a function that wrapped it:

// pages/dashboard/index.tsx (excerpt)
Dashboard.getLayout = (page: React.ReactElement) => (
  <DashboardLayout>{page}</DashboardLayout>
);

In app, this becomes a layout.tsx file in the folder, and every page below it is wrapped automatically:

// app/dashboard/layout.tsx
import { DashboardSidebar } from "@/components/dashboard-sidebar";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <DashboardSidebar />
      <main className="flex-1">{children}</main>
    </div>
  );
}

Delete the getLayout assignments as you move each dashboard page, and remove the getLayout handling from _app.tsx once no Pages routes use it.

Step 11: Clean Up

When the last route is moved:

  1. Delete pages/_app.tsx, pages/_document.tsx, pages/404.tsx, and pages/_error.tsx.
  2. Delete the empty pages directory.
  3. Search the codebase for next/router, next/head, getServerSideProps, getStaticProps, and getStaticPaths. Nothing should remain.
  4. Run next build and review the route summary. Confirm the routes you expect to be static are static and the per-request ones are dynamic.

A Migration Checklist

Use this for each route you move:

  • Page file created at app/<path>/page.tsx, old file in pages deleted.
  • Data loading moved into the async component; revalidate or fetch options preserve the old caching behavior.
  • params and searchParams awaited.
  • next/head replaced with metadata or generateMetadata.
  • Components using hooks or browser APIs marked with "use client".
  • next/router imports switched to next/navigation.
  • notFound: true and redirect returns replaced with notFound() and redirect().
  • Tested with JavaScript disabled where forms or links matter.

Things That Commonly Go Wrong

"You're importing a component that needs useState." You're using a hook in a Server Component. Add "use client" to that component's file, or move the interactive part into a smaller client component.

Styles missing on migrated pages. Global CSS imported in _app.tsx doesn't apply to app. Import it in app/layout.tsx too.

Hard reloads when navigating. Links between a pages route and an app route always do a full page load. That's expected during the migration and goes away once both ends are in the same router.

A third-party component crashes on the server. Some libraries access window at import time. Wrap them in a Client Component, and if needed load them with next/dynamic and ssr: false from inside that Client Component.

Conclusion

A Pages-to-App migration is a series of small, mechanical changes: a root layout replaces _app and _document, providers move into a Client Component, next/head becomes metadata, data functions turn into async components, getStaticPaths becomes generateStaticParams, and next/router becomes next/navigation. Doing it route by route keeps every step shippable.

Once everything lives in app, you can start using the features that motivated the move: streaming with loading.tsx, nested layouts, Server Actions, and Cache Components.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading