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.
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:
- Upgrade to Next.js 14+ (or latest)
- Create the
app/directory alongsidepages/ - Migrate shared layouts first
- Convert pages one at a time, starting with the simplest ones
- 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.
Related articles
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
- Next.js Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.
- Next.js Next.js Server Actions: Forms and Mutations Guide
Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.
- Next.js Next.js Streaming and Loading UI: Instant Pages
Build instant-loading Next.js pages with streaming, Suspense boundaries, loading.tsx files, and skeleton UIs for the best user experience.