MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js App Router vs Pages Router: Migration Guide

Migrate from Pages Router to App Router: data fetching, layouts, and breaking changes.

Next.jsApp RouterMigrationFrontend

By MinhVo

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.

Next.js architecture evolution

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

ConceptPages RouterApp Router
Directorypages/app/
ComponentsClient by defaultServer by default
Data fetchinggetServerSideProps, getStaticPropsasync components, fetch()
Layouts_app.tsx + _document.tsxlayout.tsx (nested)
Loading statesManual with stateloading.tsx (automatic)
Error handling_error.tsxerror.tsx (per segment)
Metadata<Head> componentMetadata export
Routingnext/routernext/navigation
StylingGlobal in _appScoped per layout

Migration architecture comparison

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 app

Create 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 });
}

Migration workflow diagram

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

  1. Migrate route by route, not all at once: The coexistence of pages/ and app/ is officially supported. Move one route at a time, starting with the simplest pages to build confidence.

  2. Extract data fetching into shared libraries first: Before migrating any page, pull your data fetching logic out of getServerSideProps and into standalone functions. This makes the migration mechanical — just change where the function is called.

  3. 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.

  4. 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.

  5. 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.

  6. Migrate middleware separately: If you use next.config.js rewrites or redirects, check whether they need to change for App Router routes. Middleware in middleware.ts works for both routers.

  7. 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/react with proper server component support.

  8. 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

PitfallImpactSolution
Using hooks in server componentsBuild error: "useState is not defined"Add "use client" directive to the component file
Accessing params synchronouslyRuntime error in Next.js 15+await the params prop: const { id } = await params
Mixing next/router and next/navigationStale state, broken navigationReplace all useRouter from next/router with next/navigation
Missing <html> and <body> in root layoutHydration errorsEnsure 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 dataRemove it and use async component data fetching instead
Forgetting loading.tsx for SuspensePages load as empty until data arrivesAdd 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

ApproachEffortRiskBenefit
Incremental migration (recommended)MediumLowZero downtime, learn as you go
Full rewrite from scratchVery highHighClean codebase, but major risk
Stay on Pages RouterNoneLowNo migration cost, but miss new features
Switch to a different frameworkVery highVery highNot 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:

  1. Start with the simplest pages to build confidence and establish patterns for your team.
  2. Extract data fetching into shared libraries before migrating pages — this makes the migration mechanical.
  3. Add "use client" to components that use hooks — the most common and easiest-to-fix migration error.
  4. Replace getServerSideProps with async server components — the new pattern is simpler and enables streaming.
  5. 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.