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

App Router versus Pages Router rendering The App Router streams server-component HTML then hydrates islands, while the Pages Router builds HTML from getServerSideProps and hydrates the whole tree. App Router vs Pages Router App Router server-first app/ tree Server Components Stream HTML no JS to paint Hydrate client islands Pages Router client-first pages/ file getServerSideProps HTML + props full document Hydrate whole React tree
Two rendering pipelines contrasted: the App Router streams server-rendered HTML and hydrates only interactive islands, whereas the Pages Router ships a full document from getServerSideProps and hydrates the entire React tree.

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).*)'],
};
The App Router file conventions and their roles Six App Router file conventions with the role of each: page, layout, loading, error, not-found and route handlers, including the constraint that a route handler cannot share a segment with a page. File conventions and what each controls file role page.tsx the route's own UI makes the segment routable layout.tsx shared chrome, persists across children not remounted on sibling navigation loading.tsx the suspense fallback shown while the segment streams error.tsx a client error boundary scoped to that segment not-found.tsx the 404 UI for that subtree paired with notFound() route.ts an HTTP handler, not UI cannot coexist with page.tsx
Each file is a hook into a different stage of rendering — which is why the directory is the routing configuration.

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.

Deciding Whether to Migrate at All

The App Router is not simply a newer syntax for the Pages Router; it is a different rendering model, and the migration is proportionally larger than the file moves suggest. That makes “should we migrate” a real question rather than a foregone conclusion.

The change that dominates everything else is where components run. In the Pages Router, every component is a client component and data arrives through a props-returning function. In the App Router, components are server components unless marked otherwise, and data is fetched by awaiting inside them. That inverts the default: instead of asking which pages need server rendering, you ask which components need the browser. Any component using state, effects, event handlers or browser APIs needs a directive, and finding all of them in an existing codebase is the bulk of the work.

The payoff is real. Server components ship no JavaScript, so a page whose interactive surface is small can become dramatically lighter. Layouts persist across navigations without any effort. Streaming and per-segment loading states come from the file structure rather than from code. And data fetching stops being a page-level concern, so a deeply nested component can fetch what it needs without threading props down to it.

The costs are also real. The mental model is genuinely different and takes a team time to internalise. Client and server boundaries have to be reasoned about explicitly, and the errors from getting them wrong are not always self-explanatory. Third-party libraries that assume a browser need wrapping. And an incremental migration means running both routers side by side for a period, which is supported but does mean two mental models in one repository.

The pragmatic answer for most existing applications is to migrate by area rather than wholesale, starting with the part of the site where the payoff is largest — usually the content-heavy, low-interactivity routes, where server components remove the most JavaScript for the least restructuring. Highly interactive areas can stay on the Pages Router indefinitely; they are the routes that benefit least from the change.

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.
Pages Router concepts and their App Router equivalents Six Pages Router concepts mapped to their App Router replacements, covering data fetching, static generation, shared layouts, dynamic and catch-all segments, and API routes. Pages Router concept → App Router equivalent Pages Router App Router data fetching getServerSideProps await inside a server component static generation getStaticProps fetch with a cache option shared layout a custom _app pattern layout.tsx per segment dynamic routes [id].tsx [id]/page.tsx catch-all [...slug].tsx [...slug]/page.tsx API routes pages/api/* route.ts handlers
The first two rows are the migration: everything else is a file move.

A last practical note for teams running both routers during a migration: keep the boundary at a URL prefix rather than scattering it. Next.js resolves an App Router segment ahead of the equivalent Pages Router file, so overlapping paths are decided by a rule nobody in the review will remember. Moving whole prefixes at a time keeps the answer obvious and makes each step independently revertible.