Introduction
Next.js 13 introduced the App Router — a new routing and data-fetching architecture built on React Server Components, nested layouts, and streaming. If you have an existing Next.js application using the Pages Router (pages/ directory), migrating to the App Router is both an opportunity and a challenge. The App Router offers superior performance through server-first rendering, automatic code splitting, and granular caching, but the migration path involves fundamental changes to how you fetch data, handle state, and structure your components.
This guide provides a comprehensive, step-by-step migration strategy. We'll cover every major difference — from getServerSideProps to async server components, from _app.tsx to layout.tsx, and from next/router to next/navigation. By the end, you'll have a clear roadmap for migrating your production application without downtime.
Understanding the Architectural Differences: Core Concepts
Pages Router: The Original Model
The Pages Router uses file-based routing where each file in pages/ exports a React component that becomes a route. Data fetching happens at the page level through special functions:
// pages/products/[id].tsx (Pages Router)
import { GetServerSideProps } from "next";
interface Props {
product: Product;
}
export default function ProductPage({ product }: Props) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const { id } = context.params!;
const product = await fetchProduct(id);
if (!product) {
return { notFound: true };
}
return {
props: { product },
};
};App Router: The New Model
The App Router uses React Server Components by default. Data fetching happens directly in components using async/await:
// app/products/[id]/page.tsx (App Router)
import { getProduct } from "@/lib/products";
import { notFound } from "next/navigation";
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}Side-by-Side Comparison
| Concept | Pages Router | App Router |
|---|---|---|
| Directory | pages/ | app/ |
| Components | Client by default | Server by default |
| Data fetching | getServerSideProps, getStaticProps | async components, fetch() |
| Layouts | _app.tsx + _document.tsx | layout.tsx (nested) |
| Loading states | Manual with state | loading.tsx (automatic) |
| Error handling | _error.tsx | error.tsx (per segment) |
| Metadata | <Head> component | Metadata export |
| Routing | next/router | next/navigation |
| Styling | Global in _app | Scoped per layout |
Architecture and Design Patterns
Pattern 1: Incremental Migration Strategy
The App Router and Pages Router can coexist in the same project. This enables incremental migration — move one route at a time while the rest of your app continues working:
my-app/
├── app/ # New App Router routes
│ ├── layout.tsx # Root layout for App Router pages
│ ├── dashboard/
│ │ └── page.tsx # Migrated to App Router
│ └── settings/
│ └── page.tsx # Migrated to App Router
├── pages/ # Legacy Pages Router routes
│ ├── _app.tsx
│ ├── index.tsx # Still on Pages Router
│ ├── products/
│ │ └── [id].tsx # Still on Pages Router
│ └── api/
│ └── auth.ts # API routes work in both
When both pages/ and app/ exist, Next.js resolves app/ routes first. If a route exists in both, app/ takes precedence. This means you can migrate route by route without breaking existing functionality.
Pattern 2: Shared Data Access Layer
Extract all data fetching into a shared library that both routers can use:
// lib/products.ts
export async function getProduct(id: string): Promise<Product | null> {
const response = await fetch(`${process.env.API_URL}/products/${id}`, {
next: { revalidate: 3600 }, // App Router caching
});
if (!response.ok) return null;
return response.json();
}
export async function getProducts(): Promise<Product[]> {
const response = await fetch(`${process.env.API_URL}/products`, {
next: { revalidate: 60 },
});
return response.json();
}This pattern lets you migrate data fetching incrementally — first extract the logic into shared functions, then switch each page from getServerSideProps to direct async calls.
Pattern 3: Layout Composition
The biggest architectural change is from flat layouts to nested layouts. In the Pages Router, _app.tsx wraps every page:
// pages/_app.tsx (Pages Router)
export default function App({ Component, pageProps }: AppProps) {
return (
<Layout>
<Header />
<Component {...pageProps} />
<Footer />
</Layout>
);
}In the App Router, layouts are nested per directory:
// app/layout.tsx (Root layout — wraps everything)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx (Dashboard layout — only dashboard pages)
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
);
}Step-by-Step Implementation
Step 1: Create the App Directory Structure
Start by creating the app/ directory alongside your existing pages/ directory:
mkdir -p appCreate the root layout that replaces _app.tsx and _document.tsx:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "My Application",
description: "Built with Next.js App Router",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}Step 2: Migrate Global Styles and Providers
Move your global CSS imports and context providers from _app.tsx to layout.tsx:
// app/providers.tsx
"use client";
import { ThemeProvider } from "@/components/theme-provider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Important: Client components (those using hooks, event handlers, or browser APIs) must have the "use client" directive. Server components cannot use these features.
Step 3: Migrate Data Fetching
Replace getServerSideProps with direct async component data fetching:
// BEFORE: pages/products.tsx (Pages Router)
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getSession(context.req);
const products = await getProducts();
return {
props: { products, user: session?.user ?? null },
};
};
export default function ProductsPage({ products, user }: Props) {
return (
<div>
<h1>Products</h1>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// AFTER: app/products/page.tsx (App Router)
import { getProducts } from "@/lib/products";
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function ProductsPage() {
const session = await getSession();
if (!session) redirect("/login");
const products = await getProducts();
return (
<div>
<h1>Products</h1>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}Step 4: Migrate Dynamic Routes
Replace getStaticPaths with generateStaticParams:
// BEFORE: pages/products/[id].tsx
export const getStaticPaths: GetStaticPaths = async () => {
const products = await getProducts();
return {
paths: products.map(p => ({ params: { id: p.id } })),
fallback: "blocking",
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await getProduct(params!.id as string);
if (!product) return { notFound: true };
return { props: { product }, revalidate: 3600 };
};
// AFTER: app/products/[id]/page.tsx
import { getProduct, getProducts } from "@/lib/products";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
const products = await getProducts();
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}Step 5: Migrate Metadata
Replace the <Head> component with the Metadata export:
// BEFORE: pages/products/[id].tsx
import Head from "next/head";
export default function ProductPage({ product }: Props) {
return (
<>
<Head>
<title>{product.name} | My Store</title>
<meta name="description" content={product.description} />
<meta property="og:image" content={product.image} />
</Head>
{/* ... */}
</>
);
}
// AFTER: app/products/[id]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const product = await getProduct(params.id);
if (!product) return {};
return {
title: `${product.name} | My Store`,
description: product.description,
openGraph: {
images: [{ url: product.image }],
},
};
}Step 6: Migrate Client Components
Components that use hooks, event listeners, or browser APIs need the "use client" directive:
// BEFORE: components/SearchBar.tsx (was implicitly client)
import { useState } from "react";
export function SearchBar() {
const [query, setQuery] = useState("");
// ...
}
// AFTER: components/SearchBar.tsx
"use client";
import { useState } from "react";
export function SearchBar() {
const [query, setQuery] = useState("");
// ...
}Step 7: Migrate API Routes
API routes move from pages/api/ to app/api/ and use the new Route Handler pattern:
// BEFORE: pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const products = await getProducts();
res.status(200).json(products);
} else if (req.method === "POST") {
const product = await createProduct(req.body);
res.status(201).json(product);
} else {
res.status(405).end();
}
}
// AFTER: app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET() {
const products = await getProducts();
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const product = await createProduct(body);
return NextResponse.json(product, { status: 201 });
}Real-World Use Cases
Use Case 1: E-Commerce Platform Migration
An e-commerce site with 50+ pages migrated incrementally over 4 weeks. They started with the highest-traffic pages (product listings and product detail pages) because these benefited most from streaming and ISR. The shared data layer pattern allowed them to keep the checkout flow on Pages Router until the rest was stable. Result: 40% improvement in LCP and 60% reduction in JavaScript bundle size for migrated pages.
Use Case 2: SaaS Dashboard Migration
A B2B SaaS application migrated its dashboard to take advantage of nested layouts. The dashboard had a persistent sidebar, header, and notification panel that previously re-rendered on every navigation. After migrating to App Router with nested layouts, these components only render once, reducing unnecessary re-renders by 80%.
Use Case 3: Blog and Marketing Site
A content-heavy site with 200+ blog posts migrated from getStaticProps to App Router's server components with generateStaticParams. The migration was straightforward because most pages were already statically generated. The main benefit was automatic granular caching and the ability to use revalidatePath for on-demand ISR instead of rebuilding the entire site when content changed.
Best Practices for Production
-
Migrate route by route, not all at once: The coexistence of
pages/andapp/is officially supported. Move one route at a time, starting with the simplest pages to build confidence. -
Extract data fetching into shared libraries first: Before migrating any page, pull your data fetching logic out of
getServerSidePropsand into standalone functions. This makes the migration mechanical — just change where the function is called. -
Mark client components explicitly: Add
"use client"to any component that uses React hooks, browser APIs, or event handlers. The App Router defaults to server components, and forgetting this directive is the most common migration error. -
Test with both routers active: Use the coexistence period to verify that your migrated pages work correctly alongside legacy pages. Pay special attention to shared layouts, context providers, and navigation.
-
Update your CI/CD pipeline: The App Router uses different build output and caching behavior. Update your deployment configuration to handle
app/routes correctly, especially if you use ISR. -
Migrate middleware separately: If you use
next.config.jsrewrites or redirects, check whether they need to change for App Router routes. Middleware inmiddleware.tsworks for both routers. -
Update your testing strategy: App Router server components can't be tested with traditional React Testing Library methods that assume client rendering. Use integration tests and
@testing-library/reactwith proper server component support. -
Remove Pages Router files after full migration: Once all routes are migrated, delete the
pages/directory entirely. Having both directories adds confusion and makes it unclear which router handles a given route.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using hooks in server components | Build error: "useState is not defined" | Add "use client" directive to the component file |
Accessing params synchronously | Runtime error in Next.js 15+ | await the params prop: const { id } = await params |
Mixing next/router and next/navigation | Stale state, broken navigation | Replace all useRouter from next/router with next/navigation |
Missing <html> and <body> in root layout | Hydration errors | Ensure app/layout.tsx wraps children in <html> and <body> |
| Context providers in server components | "useContext is not defined" | Wrap providers in a "use client" component |
Using getServerSideProps in app/ | It's silently ignored — no data | Remove it and use async component data fetching instead |
Forgetting loading.tsx for Suspense | Pages load as empty until data arrives | Add loading.tsx in each route directory |
Performance Optimization
The App Router provides several built-in performance optimizations:
// Automatic static optimization — pages without dynamic data are static by default
export default async function AboutPage() {
// No dynamic data fetching = automatically static
return <div>About Us</div>;
}
// Granular revalidation with ISR
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // cached with revalidate: 3600
return <ProductView product={product} />;
}
// Streaming with Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* Streams independently */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* Streams independently */}
</Suspense>
</div>
);
}Key performance gains from migration:
- Reduced client JavaScript: Server components don't ship JavaScript to the browser
- Automatic code splitting: Each route and layout loads only the code it needs
- Streaming: Pages render progressively as data becomes available
- Granular caching: Cache at the component level, not the page level
Comparison with Alternatives
| Approach | Effort | Risk | Benefit |
|---|---|---|---|
| Incremental migration (recommended) | Medium | Low | Zero downtime, learn as you go |
| Full rewrite from scratch | Very high | High | Clean codebase, but major risk |
| Stay on Pages Router | None | Low | No migration cost, but miss new features |
| Switch to a different framework | Very high | Very high | Not recommended for most teams |
Advanced Patterns
Handling Cookies and Headers
In the App Router, cookies and headers are accessed through dedicated functions:
import { cookies, headers } from "next/headers";
export default async function ProfilePage() {
const cookieStore = await cookies();
const session = cookieStore.get("session");
const headersList = await headers();
const userAgent = headersList.get("user-agent");
return (
<div>
<p>Session: {session?.value}</p>
<p>User Agent: {userAgent}</p>
</div>
);
}Migrating Middleware
// middleware.ts (works for both routers)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/profile/:path*"],
};Testing Strategies
// Integration test for migrated page
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Note: Server components require special test setup
describe("ProductPage (App Router)", () => {
it("renders product details", async () => {
// Server components return promises
const Page = await ProductPage({ params: { id: "1" } });
render(Page);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("$99.99")).toBeInTheDocument();
});
it("calls notFound for missing products", async () => {
await expect(
ProductPage({ params: { id: "nonexistent" } })
).rejects.toThrow();
});
});Future Outlook
The Next.js team has stated that the App Router is the future of the framework. While the Pages Router will continue to receive maintenance updates, all new features — Server Actions, Partial Prerendering, Cache Components — are App Router exclusive. The trend is clear: server-first rendering with selective client interactivity is the direction the React ecosystem is heading.
The React team's work on the React Compiler (formerly React Forget) will further optimize App Router applications by automatically memoizing components and reducing unnecessary re-renders, making the migration even more beneficial over time.
Conclusion
Migrating from the Pages Router to the App Router is a significant but worthwhile investment. The key is to approach it incrementally — both routers coexist seamlessly, allowing you to migrate one route at a time.
Key takeaways:
- Start with the simplest pages to build confidence and establish patterns for your team.
- Extract data fetching into shared libraries before migrating pages — this makes the migration mechanical.
- Add
"use client"to components that use hooks — the most common and easiest-to-fix migration error. - Replace
getServerSidePropswithasyncserver components — the new pattern is simpler and enables streaming. - Use nested layouts to eliminate unnecessary re-renders of shared UI elements.
The migration pays dividends through reduced JavaScript bundles, better Core Web Vitals, streaming for faster perceived load times, and access to the latest React and Next.js features. Start migrating today — your users will notice the difference.
For the official migration guide, see the Next.js App Router Migration documentation.