Skip to content
Codeloom
Next.js

Migrating to the Next.js App Router: A Step-by-Step Guide

A practical migration guide from Next.js Pages Router to App Router covering routing, data fetching, layouts, metadata, and common pitfalls.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • How to incrementally migrate from pages/ to app/ directory
  • Converting getServerSideProps and getStaticProps to server components
  • Migrating API routes to route handlers
  • Handling layouts, metadata, and error boundaries in App Router

Prerequisites

None — this post is self-contained.

The Next.js App Router, stable since Next.js 13.4, replaces the Pages Router with a new paradigm built on React Server Components. Migration does not need to happen all at once. Both routers can coexist, letting you migrate route by route. This guide walks through the process with concrete before-and-after examples.

Migration Strategy

The safest approach is incremental migration. Next.js supports both pages/ and app/ directories simultaneously. Routes in app/ take priority over matching routes in pages/. This means you can migrate one page at a time without breaking the rest of your application.

Start with these steps:

  1. Upgrade to Next.js 14+ (or latest)
  2. Create the app/ directory alongside pages/
  3. Migrate shared layouts first
  4. Convert pages one at a time, starting with the simplest ones
  5. Migrate API routes last

Converting Pages

Before: Pages Router with getServerSideProps

// pages/products/[id].tsx
import { GetServerSideProps } from 'next';

interface Product {
  id: string;
  name: string;
  price: number;
}

interface Props {
  product: Product;
}

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  const { id } = context.params!;
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();

  if (!product) {
    return { notFound: true };
  }

  return { props: { product } };
};

export default function ProductPage({ product }: Props) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
    </div>
  );
}

After: App Router with Server Component

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';

interface Product {
  id: string;
  name: string;
  price: number;
}

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    cache: 'no-store', // Equivalent to getServerSideProps
  });

  if (!res.ok) return null;
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
    </div>
  );
}

Key differences: the component itself is async, data fetching happens directly in the component body, and notFound() replaces returning { notFound: true }.

Converting getStaticProps with ISR

// Before: pages/blog/[slug].tsx
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getPost(params!.slug as string);
  return {
    props: { post },
    revalidate: 60, // ISR: revalidate every 60 seconds
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await getAllSlugs();
  return {
    paths: slugs.map((slug) => ({ params: { slug } })),
    fallback: 'blocking',
  };
};
// After: app/blog/[slug]/page.tsx
async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 }, // ISR equivalent
  });
  return res.json();
}

export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

generateStaticParams replaces getStaticPaths. The next: { revalidate } option on fetch replaces the revalidate property in getStaticProps.

Migrating Layouts

The Pages Router uses _app.tsx and _document.tsx for global layout and HTML structure. The App Router replaces both with layout.tsx and supports nested layouts.

Before: _app.tsx and _document.tsx

// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Navbar } from '../components/Navbar';
import '../styles/globals.css';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Navbar />
      <main>
        <Component {...pageProps} />
      </main>
    </>
  );
}

After: Root layout.tsx

// app/layout.tsx
import { Navbar } from '@/components/Navbar';
import './globals.css';

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

The root layout must include <html> and <body> tags, replacing the role of _document.tsx. Nested layouts are created by adding layout.tsx files in subdirectories:

// app/dashboard/layout.tsx
import { Sidebar } from '@/components/Sidebar';

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

Migrating API Routes

Before: pages/api/

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'GET') {
    const users = await db.query('SELECT * FROM users');
    return res.status(200).json(users);
  }

  if (req.method === 'POST') {
    const user = await db.create(req.body);
    return res.status(201).json(user);
  }

  res.setHeader('Allow', ['GET', 'POST']);
  res.status(405).end();
}

After: app/api/ Route Handlers

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await db.create(body);
  return NextResponse.json(user, { status: 201 });
}

Route handlers use named exports for each HTTP method, eliminating the manual method checking. They use the Web standard Request and Response APIs.

Migrating Metadata

Before: Head Component

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

export default function About() {
  return (
    <>
      <Head>
        <title>About Us</title>
        <meta name="description" content="Learn about our company" />
        <meta property="og:title" content="About Us" />
      </Head>
      <h1>About Us</h1>
    </>
  );
}

After: Metadata API

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

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn about our company',
  openGraph: {
    title: 'About Us',
  },
};

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

For dynamic metadata based on route params or fetched data:

// app/products/[id]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);

  return {
    title: product.name,
    description: `Buy ${product.name} for $${product.price}`,
  };
}

Migrating Client-Side Navigation

Before: useRouter from next/router

import { useRouter } from 'next/router';

function SearchForm() {
  const router = useRouter();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    router.push(`/search?q=${query}`);
  };

  // Access query params
  const { q } = router.query;
}

After: Separate Hooks

'use client';

import { useRouter, useSearchParams, usePathname } from 'next/navigation';

function SearchForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const pathname = usePathname();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    router.push(`/search?q=${query}`);
  };

  // Access query params
  const q = searchParams.get('q');
}

Note the import path changes from next/router to next/navigation, and router.query is replaced by useSearchParams().

Error and Loading States

Before: Custom Error Handling

// pages/products/[id].tsx
export const getServerSideProps = async (context) => {
  try {
    const product = await fetchProduct(context.params.id);
    return { props: { product } };
  } catch {
    return { props: { error: true } };
  }
};

After: error.tsx and loading.tsx

// app/products/[id]/loading.tsx
export default function Loading() {
  return <div className="skeleton">Loading product...</div>;
}

// app/products/[id]/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

The loading.tsx file automatically wraps the page in a React Suspense boundary. The error.tsx file wraps it in an error boundary with a built-in reset mechanism.

Common Migration Pitfalls

Forgetting ‘use client’. Components that use hooks (useState, useEffect, event handlers) must be marked with 'use client' at the top of the file. Server components are the default in App Router.

Importing from wrong package. Use next/navigation instead of next/router in App Router. The old import will not work.

Fetch caching behavior. In App Router, fetch is cached by default (equivalent to getStaticProps). Use cache: 'no-store' for dynamic data or next: { revalidate: N } for ISR.

Context providers. Global providers (theme, auth, state management) must be wrapped in a client component and placed in the root layout.

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

import { ThemeProvider } from 'next-themes';
import { SessionProvider } from 'next-auth/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SessionProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </SessionProvider>
  );
}

Wrapping Up

Migrating to the App Router is an incremental process. Start by creating the app/ directory and migrating one page at a time. Replace getServerSideProps and getStaticProps with async server components and fetch options. Convert API routes to route handlers with named HTTP method exports. Move metadata from Head to the Metadata API. The key advantage of the App Router is simpler data fetching, nested layouts that persist across navigation, and built-in streaming with loading and error states.