Next.js App Router vs Pages Router

The two routers shipped with Next.js are not two flavours of the same idea — they are two different rendering models that happen to share a build tool. The Pages Router maps pages/*.tsx files to URLs and ships a client-rendered React tree hydrated from props produced by getServerSideProps or getStaticProps. The App Router maps nested app/ directories to URLs, renders every component as a React Server Component (RSC) by default, and streams HTML to the browser before any JavaScript executes. This page is for engineers who have to decide which model a new feature belongs in, and who need a concrete, copy-paste migration path when a pages/ codebase needs RSC, streaming, or nested layouts that the old router cannot express. We compare the primitives directly, give you a working incremental migration, and show how to verify that navigation, caching, and metadata behave the way you expect.

← Back to Framework-Specific Routing Patterns

The Problem

The temptation is to treat “App Router vs Pages Router” as a styling preference — pick one and move on. It is not. Choose wrong and the failure modes are structural, not cosmetic.

Pick the Pages Router for an app that genuinely needs server-only data access and you ship secrets, ORMs, and oversized dependencies into the client bundle, because every component on the page is a client component whether it needs to be or not. Hydration cost grows linearly with the page, and getServerSideProps blocks the entire response until the slowest query resolves — there is no partial rendering, so a single slow widget delays the whole document.

Pick the App Router for a UI that is mostly interactive and you scatter "use client" across the tree, lose the RSC payload savings you migrated for, and hit subtle caching surprises: the App Router caches fetch() results, route segments, and the client-side Router Cache by default, so data that looked fresh in the Pages Router silently goes stale. Mixing the two models carelessly — a next/link here, a window.location there — breaks soft navigation and invalidates prefetch, turning a fast SPA into a sequence of full document reloads.

Getting the boundary right is the same discipline you apply across Framework-Specific Routing Patterns: decide where rendering happens, then make the data and navigation contracts explicit at that boundary.

There is also a quieter cost that does not show up until production: developer mental model drift. In a Pages Router team, everyone knows that a page is a client tree fed by props, that useRouter().query holds the params, and that data is fresh per request unless you opt into static generation. The App Router inverts every one of those assumptions — params arrive as function arguments, half the components never run in the browser at all, and fetch is cached unless you say otherwise. A codebase that drifts half-way across this boundary, with some routes on each model and no written rule for which goes where, is harder to reason about than a codebase committed fully to either router. The decision is therefore as much organisational as technical: pick the model per feature deliberately, document the rule, and keep the boundary legible to the next engineer who opens the directory.

Core API & Primitives

The routers expose overlapping concepts under different names. The table below is the mapping you will reach for constantly; the signatures that follow are the ones that change behaviour.

Concern Pages Router App Router
Route definition pages/blog/[slug].tsx app/blog/[slug]/page.tsx
Shared shell pages/_app.tsx, _document.tsx nested app/**/layout.tsx
Server data getServerSideProps / getStaticProps async Server Component + fetch
Metadata next/head metadata / generateMetadata
Loading state manual loading.tsx (Suspense)
Error state _error.tsx error.tsx (error boundary)
404 pages/404.tsx not-found.tsx + notFound()
Dynamic API useRouter from next/router useRouter from next/navigation

The signatures that matter for correctness:

// next 14.2 — App Router page props are async params, not a flat object
type PageProps = {
  params: { slug: string };
  searchParams: Record<string, string | string[] | undefined>;
};

export async function generateMetadata(
  { params }: PageProps,
): Promise<import('next').Metadata> {
  return { title: `Post: ${params.slug}` };
}

export default async function Page({ params, searchParams }: PageProps) {
  // params/searchParams are passed in — there is no useRouter() here
  return <article data-slug={params.slug} />;
}
// next 14.2 — Pages Router equivalent: data arrives as props, not awaited inline
import type { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const slug = ctx.params?.slug as string;
  return { props: { slug } };
};

export default function Page({ slug }: { slug: string }) {
  return <article data-slug={slug} />;
}

The cache primitives are App Router-only and are the most common source of “it worked in dev” bugs:

// next 14.2 — the four ways to control freshness in the App Router
const a = await fetch(url, { next: { revalidate: 3600 } }); // ISR-style, 1h
const b = await fetch(url, { cache: 'no-store' });          // always fresh
const c = await fetch(url, { next: { tags: ['products'] } }); // revalidateTag()
export const dynamic = 'force-dynamic';                      // opt whole route out of caching

Step-by-Step Implementation

Prerequisite: Next.js 14.2+, Node.js 18.17+, and a TypeScript project; the App Router and Pages Router can coexist in the same project during migration, with app/ routes taking precedence over pages/ routes of the same path.

Step 1: Establish the root layout

The App Router replaces _app.tsx and _document.tsx with a single app/layout.tsx that owns the <html> and <body> tags. Metadata moves from imperative next/head calls to a declarative export.

// app/layout.tsx — next 14.2; replaces _app.tsx + _document.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  // template applies a suffix to every child page's title automatically
  title: { default: 'Next.js App', template: '%s | Next.js App' },
  description: 'Optimised routing with React Server Components',
};

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

Step 2: Convert a data-fetching page to a Server Component

A page that used getServerSideProps becomes an async function that awaits its data inline. There is no props plumbing and no serialisation boundary — the data never leaves the server unless a child client component receives it.

// app/products/page.tsx — Server Component; no getServerSideProps needed
const ProductGrid = ({ products }: { products: { id: string; name: string }[] }) => (
  <ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
);

export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }, // cache for an hour, then revalidate
  });
  if (!res.ok) throw new Error('Failed to fetch products'); // caught by error.tsx
  // MUST await .json() — passing the raw Response renders nothing
  const products = await res.json();
  return <ProductGrid products={products} />;
}

Step 3: Add streaming with loading and error boundaries

loading.tsx and error.tsx are file-convention Suspense and error boundaries scoped to a route segment. The shell streams immediately while the awaited data resolves, eliminating the all-or-nothing wait of getServerSideProps.

// app/products/loading.tsx — streamed instantly while the page awaits data
export default function Loading() {
  return <ul aria-busy="true"><li>Loading products…</li></ul>;
}
// app/products/error.tsx — error boundaries MUST be client components
'use client';

export default function Error({ reset }: { error: Error; reset: () => void }) {
  return (
    <div role="alert">
      <p>Could not load products.</p>
      <button onClick={() => reset()}>Retry</button>
    </div>
  );
}

Step 4: Mark the interactive leaves as client components

Anything using window, localStorage, state, effects, or event handlers needs the "use client" directive. Keep it at the leaves so the bulk of the tree stays server-rendered. Note the import path change — useRouter now comes from next/navigation, not next/router.

// app/products/filter.tsx — client island inside a server page
'use client';
import { useRouter, useSearchParams } from 'next/navigation';

export function CategoryFilter() {
  const router = useRouter();
  const params = useSearchParams();
  return (
    <select
      value={params.get('category') ?? 'all'}
      onChange={(e) => router.push(`?category=${e.target.value}`)}
    >
      <option value="all">All</option>
      <option value="books">Books</option>
    </select>
  );
}

Step 5: Run the routers side by side during migration

Leave pages/ in place and add app/ routes one feature at a time. Middleware runs for both, so you can route, rewrite, and negotiate locale uniformly while the migration is in flight. The same fallback thinking applies as in any Fallback Routing Strategies plan.

// middleware.ts — next 14.2; runs for both app/ and pages/ routes
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = /^\/(en|es|fr)\//.test(pathname);
  if (!hasLocale) {
    const locale = request.cookies.get('NEXT_LOCALE')?.value ?? 'en';
    const url = request.nextUrl.clone();
    url.pathname = `/${locale}${pathname}`;
    return NextResponse.rewrite(url);
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Verification & Testing

The behaviours worth asserting are that navigation is soft (no full document reload) and that streaming actually streams. The first you can verify with Playwright by checking that the document object survives a link click; the second by confirming the loading shell appears before the data.

// playwright v1.44 — soft navigation keeps the same document instance
import { test, expect } from '@playwright/test';

test('App Router link does a soft navigation', async ({ page }) => {
  await page.goto('/products');
  // stamp the live document; a full reload would wipe this marker
  await page.evaluate(() => ((window as any).__nav = 'stamped'));
  await page.getByRole('link', { name: 'Books' }).click();
  await expect(page).toHaveURL(/category=books/);
  // marker survives ⇒ no full reload ⇒ client-side soft navigation worked
  expect(await page.evaluate(() => (window as any).__nav)).toBe('stamped');
});

test('loading.tsx streams before data resolves', async ({ page }) => {
  await page.goto('/products');
  // the busy shell should be observable in the streamed HTML
  await expect(page.locator('[aria-busy="true"]')).toBeVisible();
});

In DevTools, open the Network panel and click an internal link: a soft navigation issues a small RSC payload request (look for the ?_rsc= query and text/x-component content type) rather than re-downloading the full HTML document. If you see a document-type request, navigation has fallen back to a hard reload.

Performance Tuning

  • Push the "use client" boundary down. Each client component and its imports land in the bundle. Convert containers to Server Components and keep only the interactive leaves as clients; measure the saving with next build, which prints per-route First Load JS.
  • Let prefetching work. <Link> prefetches static routes on viewport entry and dynamic routes on hover by default. Replacing it with manual router.push on hover, or with window.location, discards this — confirm prefetch requests fire in the Network panel.
  • Tune freshness deliberately. Set next: { revalidate } per fetch rather than reaching for dynamic = 'force-dynamic' on the whole route; the latter opts the entire segment out of static rendering and re-runs every request.
  • Cache by tag for surgical invalidation. Tag fetches with next: { tags } and call revalidateTag from a Server Action or route handler so a single mutation refreshes only the affected data instead of busting a time window.
  • Stream the slow parts. Wrap a slow widget in its own Suspense boundary so the rest of the route renders immediately; this lifts LCP without changing the data layer, something getServerSideProps could never do because it blocks the whole response.

Gotchas & Failure Modes

  • Passing a Response instead of its body. fetch() in a Server Component returns a Response; you must await res.json(). Handing the raw Response to a component renders nothing and throws a serialisation error.
  • Wrong useRouter import. next/router is Pages Router; next/navigation is App Router. Importing the wrong one fails at runtime, and the App Router version has no query object — read params from props and useSearchParams instead.
  • The Router Cache hides stale data. The client-side Router Cache serves previously-visited segments without refetching. After a mutation, call router.refresh() or revalidatePath/revalidateTag; otherwise the UI shows old data despite the server being correct.
  • Browser APIs in Server Components. Touching window, document, or localStorage without "use client" either crashes the server render or produces a hydration mismatch. Mark the component as a client island.
  • Route group and parallel-slot typos. A misplaced () group or @slot directory silently changes which layout wraps a page, or drops a parallel slot entirely. Validate the directory tree before deploy — see Handling Parallel Routes in Next.js 14.
  • Expecting getServerSideProps semantics from caching. Pages Router data is fresh per request unless you opt into ISR; App Router fetch is cached by default. The mental model inverts, and dynamic segments still need notFound() or redirect() for missing params.

Go Deeper