Pages Router

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the Pages Router in full, because a very large number of production applications, and both requester-provided books this section consulted, are written with it, and the App Router only where it matters for migration: what replaces each Pages Router file and function, and how the two routers run side by side while you move. Caching matters here too, so the page says which model applies: the Pages Router has its own, much smaller set of caches that cacheComponents does not change (the previous model from the App Router’s point of view), and Cache Components only enters the picture for the routes you move into app. Every example was built with next build and served with next start (Node.js 24.16, macOS) in a scratch copy of the Bookshelf — the catalogue of books this section grows — and the results quoted below are what curl returned.

The Pages Router is not deprecated or removed: it ships in Next.js 16.3 with its own documentation tree (the index is at https://nextjs.org/docs/pages/llms.txt), and the 16.x line is the one under Active LTS in the support policy. The next package still accepts React 18 as well as React 19 in its peer range, which is one of the reasons a codebase can stay on pages while it upgrades other things. What it does not get is the new work: Server Components, Server Actions, Cache Components and Instant Navigations are App Router features. Read this page if you maintain a Pages Router app, if you have to read one (the books do), or if you are planning to leave it. The routing, rendering and caching ideas are explained from the App Router side on Routing Fundamentals, Server-Side Rendering, Static Site Generation and Incremental Static Regeneration, each of which also has a Pages Router comparison that this page consolidates.

The Pages Router at a Glance

In the Pages Router every file in pages/ that exports a React component is a route, every file in pages/api/ is a server-only endpoint, and a handful of reserved files (_app, _document, _error, 404, 500) customise how pages are initialised, wrapped in HTML and shown when something goes wrong. Data is not fetched by components: it is fetched by an exported data function next to the page, which runs on the server and hands its result to the component as props. The page component itself is a normal React component that is rendered on the server (for the first HTML) and again in the browser (hydration), which is why, in the App Router’s vocabulary, every Pages Router component is a Client Component.

Table 1. Where each Pages Router concept lives
Concept File or export Runs

Route

A file in pages/, for example pages/books/[id].tsx

Build, request or browser, depending on its data function

Page-wide wrapper

pages/_app.tsx

Server for the first render, browser for every navigation

HTML skeleton

pages/_document.tsx

Server only, never in the browser

Error pages

pages/404.tsx, pages/500.tsx, pages/_error.tsx

Build (404, 500) or request (_error)

Endpoint

A file in pages/api/

Request, server only

Data for a page

getStaticProps, getStaticPaths, getServerSideProps or getInitialProps

Build or request (getInitialProps also in the browser)

Navigation

next/link and next/router

Browser (and server, for the initial render)

The scratch app used on this page has these routes. next build prints them in a table headed Route (pages):

The route table of the Pages Router Bookshelf (from next build)
Route (pages)        Revalidate  Expire
┌ ○ /
├   /_app
├ ○ /404
├ ○ /500
├ ƒ /api/books
├ ƒ /api/books/[id]
├ ƒ /api/revalidate
├ ƒ /books
├   /books/[id]              1m      1y
│ ├ ● /books/1
│ └ ● /books/2
└ ƒ /legacy

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

The symbols are the ones used on the App Router pages: ○ is a statically optimised page, ● a page generated with getStaticProps (the table lists each path that getStaticPaths produced), and ƒ a page rendered per request. /_app has no symbol because it is not a route. /books/[id] shows a Revalidate of 1m because its getStaticProps returns revalidate: 60, and an Expire of 1y. The legacy /legacy page is ƒ because it uses getInitialProps, which, as explained below, always renders on request.

The pages Directory and File-System Routing

The Pages Router maps files to URLs. The conventions are the ones the App Router later kept in spirit but not in file names (there is no page.tsx here: the file is the page).

Table 2. How pages/ files map to routes
File URL Notes

pages/index.tsx

/

An index file maps to its folder.

pages/books/index.tsx

/books

Same rule one level down.

pages/books/[id].tsx

/books/1, /books/2

A dynamic segment; the value is in params and in router.query.

pages/docs/[…​slug].tsx

/docs/a, /docs/a/b

A catch-all segment; the value is an array.

pages/docs/[[…​slug]].tsx

/docs and /docs/a/b

An optional catch-all: also matches the route without a value.

pages/api/books/[id].ts

/api/books/1

Anything in pages/api is an API route, not a page.

pages/_app.tsx, pages/_document.tsx, pages/_error.tsx

none

Reserved files; not reachable as routes.

pages/404.tsx, pages/500.tsx

none (shown on errors)

Statically generated error pages.

A specific route wins over a dynamic one, and a dynamic one over a catch-all (the official dynamic routes page lists the exact precedence). One practical limit is worth remembering: every file under pages/ that matches the configured page extensions is a route, so you do not colocate components or tests there the way the App Router lets you colocate files next to page.tsx; keep them in a components/ folder (as the examples do) or restrict the extensions with pageExtensions in next.config.ts.

The dynamic route and its helpers are the best illustration of how the Pages Router splits a route in two: the component renders, and the data functions decide what to render and when. The [id] page used throughout this page is shown in full in the data functions section; the dynamic-routes mechanics (parameters, precedence, catch-alls) are explained, with the App Router syntax, on Dynamic Routes and Route Groups.

_app: Wrapping Every Page

pages/_app.tsx overrides the App component Next.js uses to initialise every page. It receives Component (the active page; it changes on every client-side navigation) and pageProps (the props the page’s data function returned, or an empty object). It is the place to render a layout that persists across navigations, to inject shared data, and to import global CSS — in the Pages Router a global stylesheet may only be imported here. Providers for React Context, a Redux store or an authentication session wrap Component in this file; that is the pattern both books use, and it is also why a Pages Router app is hard to make lean: whatever _app imports is bundled into every page.

pages/_app.tsx: a per-page layout through the getLayout pattern (verified: build and curl)
import "@/styles/globals.css";
import type { ReactElement, ReactNode } from "react";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import Layout from "@/components/Layout";

export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode;
};

type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout };

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout ?? ((page) => <Layout>{page}</Layout>);
  return getLayout(<Component {...pageProps} />);
}

A page opts into a different layout by setting Page.getLayout, which _app calls (the official pages-and-layouts page calls this the per-page layouts pattern). Component.getLayout is the single piece of layout machinery of the Pages Router; it is replaced by native nested layouts in the App Router, as the migration section shows. Three caveats from the official page matter in practice:

  • App does not support getStaticProps or getServerSideProps. Data that every page needs has to be fetched by each page, or you reach for getInitialProps on App.

  • getInitialProps on App is documented as a pattern the Next.js team does not recommend: it disables Automatic Static Optimization for every page that has no getStaticProps, and the page points to incremental adoption of the App Router instead.

  • If you add pages/_app while the dev server is running and it did not exist before, restart the server.

_document: The HTML Skeleton

pages/_document.tsx customises the <html> and <body> that wrap every page. It is rendered only on the server, so event handlers such as onClick do not work in it, and the components it uses (Html, Head, Main and NextScript, all imported from next/document) are all required for the page to render correctly.

pages/_document.tsx: the document with a language and a site-wide meta tag (verified)
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <meta name="application-name" content="Bookshelf" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

curl of any page returned <html lang="en"> and the application-name meta tag in <head>. The caveats are the reverse of `_app’s:

  • The Head from next/document is not next/head. Use it only for tags shared by every page; use next/head in pages and components for the rest (titles, per-page meta). The next section of this page covers next/head.

  • React components outside <Main /> are not initialised in the browser, so do not put application logic or custom CSS such as styled-jsx there; use a layout in _app for shared UI.

  • Document does not support getStaticProps or getServerSideProps.

  • A getInitialProps on a class Document is used for libraries that need to customise renderPage (CSS-in-JS server rendering). It is not called on client-side transitions, the official page again does not recommend it, and if you do use it you must check that ctx.req is defined before assuming the page was rendered per request, because it is undefined for prerendered pages.

next/head renders elements into the document head from inside any component, which is how the Pages Router handles titles and meta tags. Two details from the official page and from our run: elements must be direct children of Head (or wrapped in at most one level of fragment or array) or they are not picked up on client-side navigations, and the <title> should receive one string. In the scratch app <title>{book.title} - Bookshelf</title>, whose children React sees as an array, produced an empty <title></title> in the server HTML, while the template literal form <title>{${book.title} - Bookshelf`}</title>` produced <title data-next-head="">Dune - Bookshelf</title>. In the App Router next/head is replaced by the Metadata API (see the migration table).

_error, 404 and 500

The Pages Router has three levels of error pages, all in pages/:

  • 404.tsx is the page shown when no route matches (and when a data function returns notFound: true). It is generated statically at build time, so it costs nothing per hit; Next.js provides a default 404 if you add none.

  • 500.tsx is the page shown when a server-side error occurs. It is also generated statically.

  • _error.tsx is the advanced hook. 500 errors are handled by the Error component on both the server and the client, and _error overrides it. It is only used in production: in development you get the error overlay with the call stack instead.

pages/404.tsx, pages/500.tsx and pages/_error.tsx (verified)
// pages/404.tsx
export default function Custom404() {
  return <h1>404 - Page Not Found</h1>;
}

// pages/500.tsx
export default function Custom500() {
  return <h1>500 - Server-side error occurred</h1>;
}

// pages/_error.tsx
import type { NextPageContext } from "next";

function Error({ statusCode }: { statusCode?: number }) {
  return (
    <p>{statusCode ? `An error ${statusCode} occurred on server` : "An error occurred on client"}</p>
  );
}

Error.getInitialProps = ({ res, err }: NextPageContext) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
  return { statusCode };
};

export default Error;

After next build and next start, curl -i /nope returned 404 with the body 404 - Page Not Found (the generated .next/server/pages/404.html contains it). A getServerSideProps that throws, on a temporary /boom page, returned 500 with 500 - Server-side error occurred and logged the stack trace on the server. Because 404.tsx and 500.tsx are rendered inside your custom _app, your global styles and theme apply to them, unlike the built-in pages, which follow the operating system’s colour scheme.

To show the built-in error page from a page (for example when an upstream API fails) import Error from next/error and render it with a statusCode; the component also accepts a title. If you define your own _error, import that one instead. _error, like _app, is a reserved pathname: /_error rendered directly returns 404 (our curl /_error did), and _error does not support the data functions.

API Routes

Any file in pages/api/ is mapped to /api/* and treated as an endpoint instead of a page. API routes are server-only bundles: they add nothing to the client bundle. The handler receives a Node.js req (http.IncomingMessage, extended with req.cookies, req.query and req.body, the body parsed by content-type) and a res (http.ServerResponse, extended with res.status(code), res.json(body), res.send(body), res.redirect([status,] path) and res.revalidate(urlPath)). This is the Node.js-style API that the books use; it is not the Web Request/Response API of App Router Route Handlers.

pages/api/books/index.ts: one handler, several HTTP methods (verified)
import type { NextApiRequest, NextApiResponse } from "next";
import { addBook, listBooks, type Book } from "@/lib/books";

type Data = Book[] | Book | { error: string };

export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  switch (req.method) {
    case "GET": {
      const q = typeof req.query.q === "string" ? req.query.q : "";
      return res.status(200).json(await listBooks(q));
    }
    case "POST": {
      const { title, author } = req.body ?? {};
      if (typeof title !== "string" || typeof author !== "string" || !title || !author) {
        return res.status(400).json({ error: "title and author are required" });
      }
      return res.status(201).json(await addBook({ title, author }));
    }
    default:
      res.setHeader("Allow", ["GET", "POST"]);
      return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
  }
}
Calling the API route (results from curl)
$ curl -i "http://localhost:3902/api/books?q=dune"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":"1","title":"Dune","author":"Frank Herbert","priceCents":999}]

$ curl -i -X POST http://localhost:3902/api/books -H 'content-type: application/json' \
    -d '{"title":"Hyperion","author":"Dan Simmons"}'
HTTP/1.1 201 Created

{"id":"4","priceCents":0,"title":"Hyperion","author":"Dan Simmons"}

$ curl -i -X POST http://localhost:3902/api/books -H 'content-type: application/json' -d '{"title":""}'
HTTP/1.1 400 Bad Request

{"error":"title and author are required"}

$ curl -i -X DELETE http://localhost:3902/api/books
HTTP/1.1 405 Method Not Allowed
Allow: GET
Allow: POST

{"error":"Method DELETE Not Allowed"}

The behaviour to remember about API routes:

  • They are same-origin only by default: no CORS headers are sent. To allow other origins wrap the handler with CORS helpers (the official page links an example).

  • They cannot be used with a static export (output: 'export'); Route Handlers in the App Router can, for GET.

  • They are affected by pageExtensions.

  • req.body is typed any in NextApiRequest because a client may send anything. Validate the shape at runtime, as the example does (a schema library such as Zod is the usual tool). Never trust the body for prices or permissions: the handler is public.

  • Dynamic API routes ([id].ts), catch-all ([…​slug].ts) and optional catch-all ([[…​slug]].ts) follow the same rules as pages, and predefined routes win over dynamic ones.

  • An API route can stream a response; the official page has a section on it.

  • The migration guide notes that if you used API routes only to call an external API from the client, Server Components can fetch it securely instead.

res.revalidate is the Pages Router’s on-demand ISR trigger and must be protected, because anyone who can call it can make your server regenerate pages. The example reads the token from the environment (the value shown is a placeholder):

pages/api/revalidate.ts: on-demand revalidation behind a shared secret (verified)
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") return res.status(405).end();
  if (req.headers["x-revalidate-token"] !== process.env.REVALIDATE_TOKEN) {
    return res.status(401).json({ message: "Invalid token" });
  }
  try {
    await res.revalidate("/books/1");
    return res.json({ revalidated: true });
  } catch {
    return res.status(500).send("Error revalidating");
  }
}
Calling it with and without the token (REVALIDATE_TOKEN is set in the server’s environment)
$ curl -i -X POST http://localhost:3902/api/revalidate
HTTP/1.1 401 Unauthorized

{"message":"Invalid token"}

$ curl -i -X POST -H "x-revalidate-token: $REVALIDATE_TOKEN" http://localhost:3902/api/revalidate
HTTP/1.1 200 OK

{"revalidated":true}

GET on the same route returned 405. The App Router replacement is revalidatePath or revalidateTag called from a Route Handler or a Server Action, covered on Incremental Static Regeneration and Route Handlers and Backend for Frontend.

next/router

The Pages Router has two ways to navigate: the Link component from next/link (declarative, prefetches in production) and the router from next/router (programmatic). useRouter() is a React Hook, so it cannot be used in class components; withRouter is the class-component alternative, and a function component can wrap a class.

Table 3. The router object returned by useRouter from next/router
Field Meaning

pathname

The route file’s path after /pages, for example /books/[id]; excludes basePath, locale and the trailing slash.

query

The query string and the dynamic route parameters as an object. Empty during prerendering when the page does not use server-side rendering.

asPath

The path as shown in the browser, including the search params; excludes basePath and locale.

isReady

Whether the router fields are updated on the client. Use it inside effects, not to render different HTML on the server.

isFallback

Whether the current page is shown in getStaticPaths fallback mode.

isPreview

Whether the application is in preview mode.

basePath, locale, locales, defaultLocale, domainLocales

The configured base path and i18n values, if enabled.

The methods are router.push(url, as, options) (adds a history entry), router.replace (same parameters, no new entry), router.prefetch (production only; Next.js does not prefetch in development), router.back, router.reload (a full reload), router.beforePopState(cb) to intercept popstate events, and router.events. url can be a string or a URL object ({ pathname: "/books", query: { q } }), and the options include shallow to change the URL without re-running the data functions. External URLs do not need the router: use window.location. The router fires routeChangeStart, routeChangeComplete, routeChangeError, beforeHistoryChange, hashChangeStart and hashChangeComplete; each callback receives the URL and an object with shallow.

components/SearchBox.tsx: programmatic navigation and router events (built; the SSR output was checked, the click handling was not run in a browser)
import { useEffect, useState, type FormEvent } from "react";
import { useRouter } from "next/router";

export default function SearchBox() {
  const router = useRouter();
  const [q, setQ] = useState("");

  useEffect(() => {
    const done = (url: string) => console.log("navigated to", url);
    router.events.on("routeChangeComplete", done);
    return () => router.events.off("routeChangeComplete", done);
  }, [router.events]);

  function onSubmit(e: FormEvent) {
    e.preventDefault();
    router.push({ pathname: "/books", query: q ? { q } : {} });
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search books" />
      <button type="submit">Search</button>
    </form>
  );
}

Subscribe to events in an effect and unsubscribe in its cleanup, as shown; _app is a convenient place for global handlers because it is not unmounted on navigation. When a navigation to the same page with a different parameter must reset component state, either update the state in an effect or give the component a key so React remounts it.

Two version-specific facts matter when the two routers coexist. useRouter from next/router is not supported in the app directory (it can keep being used in pages); the App Router has its own useRouter from next/navigation that does not return pathname or query, which are replaced by usePathname, useSearchParams and useParams. And next/compat/router exports a useRouter that returns null instead of throwing when the Pages Router is not mounted, which lets one component run in both routers during a migration. For linking details see Linking and Navigation.

The Data Functions

A page that needs data exports one of three functions, and each one decides when the page is rendered. They run only on the server (their code is removed from the client bundle), and what they return reaches the browser as JSON serialised into the page, so it must be serialisable (no Date, Map or Set) and nothing secret may be returned as a prop.

Table 4. The data functions side by side
getStaticProps getServerSideProps getInitialProps

Runs

At build time, and again on revalidation

On every request

On the server for the first load, and again in the browser on client-side navigation

Result

HTML and JSON, served as static files

HTML and JSON per request

HTML per request, props in the page

Route table symbol

● (or ○)

ƒ

ƒ

Context

params, draftMode, locale, revalidateReason, deprecated preview and previewData

params, req, res, query, resolvedUrl, draftMode, locale, deprecated preview and previewData

pathname, query, asPath, req, res, err

Return

props, revalidate, notFound, redirect

props, notFound, redirect

An object that becomes the props

Status

Current API

Current API

Legacy: the docs recommend the other two

getStaticProps and getStaticPaths

getStaticProps generates the page at build time (static site generation); for a dynamic route it is paired with getStaticPaths, which lists the paths to build and what to do with the rest through fallback. getStaticProps returns props, and optionally revalidate (seconds after which the page is regenerated in the background: ISR), notFound (serve the 404 page) or redirect. getStaticPaths returns paths (an array of params objects whose keys match the route’s brackets; an array for a catch-all) and fallback.

pages/books/[id].tsx: static generation, a fallback and ISR in one page (verified)
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { getBook, listBooks, type Book } from "@/lib/books";

export const getStaticPaths = (async () => {
  const books = await listBooks();
  return {
    // build only the first two books; the rest are generated on first request
    paths: books.slice(0, 2).map((b) => ({ params: { id: b.id } })),
    fallback: "blocking",
  };
}) satisfies GetStaticPaths;

export const getStaticProps = (async ({ params }) => {
  const book = await getBook(params!.id as string);
  if (!book) return { notFound: true, revalidate: 60 };
  return { props: { book }, revalidate: 60 };
}) satisfies GetStaticProps<{ book: Book }, { id: string }>;

export default function BookPage({ book }: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter();
  if (router.isFallback) return <p>Loading...</p>;
  return (
    <main>
      <Head>
        <title>{`${book.title} - Bookshelf`}</title>
      </Head>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
    </main>
  );
}

next build generated /books/1 and /books/2 (the route table above, and .next/server/pages/books/1.html with its .json and .meta siblings). Served by next start:

Results of curl -i against the static pages (headers abridged)
$ curl -i http://localhost:3902/books/1
HTTP/1.1 200 OK
x-nextjs-cache: HIT
Cache-Control: s-maxage=60, stale-while-revalidate=31535940
...<title data-next-head="">Dune - Bookshelf</title>...

$ curl -i http://localhost:3902/books/3        # not built: first request renders it
HTTP/1.1 200 OK
x-nextjs-cache: MISS
Cache-Control: s-maxage=60, stale-while-revalidate=31535940

$ curl -i http://localhost:3902/books/3        # second request is served from the cache
HTTP/1.1 200 OK
x-nextjs-cache: HIT

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3902/books/99
404

The three fallback values of getStaticPaths decide what happens for a path that was not built:

Table 5. The fallback modes
Value Behaviour for an unbuilt path Replaced in the App Router by

false

A 404 page. Only the paths you returned exist.

dynamicParams = false

true

Serves a fallback version of the page (props empty, router.isFallback is true) while Next.js generates the HTML and JSON in the background, then swaps in the full page. Crawlers are not served the fallback; they get blocking behaviour.

dynamicParams = true (the default), with streaming and a Suspense fallback

'blocking'

The server renders the page on the first request and returns the HTML; no loading state. Subsequent requests are served from the cache.

dynamicParams = true; the guide says the difference from true is negligible with streaming

Both true and 'blocking' are not supported with output: 'export'. The scratch app used 'blocking': the first request for /books/3 returned x-nextjs-cache: MISS and the second HIT, as the table says, and getStaticProps returning notFound: true for /books/99 produced the 404 page with status 404.

getStaticProps has a few more rules. Read files with process.cwd() rather than __dirname, because the code is compiled and moved. The revalidateReason context value tells you whether a run is for build, stale or on-demand. In development getStaticProps runs on every request, so it does not show the production behaviour. The x-nextjs-cache header reports MISS, STALE or HIT. How ISR works in depth, including the on-demand and multi-instance cases, is on Incremental Static Regeneration; the build-time side on Static Site Generation.

getServerSideProps

getServerSideProps runs on every request and returns props, notFound or redirect. The context carries req and res (the Node.js objects, with req.cookies), query, params, resolvedUrl (the normalised URL, with the _next/data prefix removed for client transitions), draftMode, and the locale values if i18n is enabled. Use it when a page depends on the request (a cookie, a header, a query string) or on data that must be current.

pages/books/index.tsx: a server-rendered search page with its own caching header (verified)
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Link from "next/link";
import SearchBox from "@/components/SearchBox";
import { listBooks, type Book } from "@/lib/books";

export const getServerSideProps = (async ({ query, res }) => {
  const q = typeof query.q === "string" ? query.q : "";
  res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
  return { props: { q, books: await listBooks(q) } };
}) satisfies GetServerSideProps<{ q: string; books: Book[] }>;

export default function Books({ q, books }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      <h1>Books{q ? ` matching "${q}"` : ""}</h1>
      <SearchBox />
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link> by {b.author}
          </li>
        ))}
      </ul>
    </main>
  );
}
The response of curl -i "http://localhost:3902/books?q=sol" (abridged)
HTTP/1.1 200 OK
Cache-Control: public, s-maxage=10, stale-while-revalidate=59
...<li><a href="/books/3">Solaris</a> by Stanislaw Lem</li>...
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"q":"sol","books":[{"id":"3","title":"Solaris","author":"Stanislaw Lem","priceCents":799}]},"__N_SSP":true},"page":"/books","query":{"q":"sol"},...,"gssp":true,...}</script>

Two things show in this output. The HTML carries the markup, and NEXT_DATA carries the same props as JSON for hydration, so anything returned from the function is public: do not return secrets, tokens or whole database rows when the component needs two fields. And the page sets its own Cache-Control through res.setHeader, which is how you let a CDN cache a server-rendered page for ten seconds with stale-while-revalidate. On a client-side navigation the browser asks the server for the page’s data as JSON (we saw /_next/data/<buildId>/books.json?q=dune return {"pageProps":{…​},"__N_SSP":true}), which is an implementation detail, not an API, but is worth recognising in the network panel.

The server-side rendering side by side with the App Router is on Server-Side Rendering; authentication in getServerSideProps, and why a check there is not enough on its own, is on Authentication and Authorization.

getInitialProps

getInitialProps is an async function attached to the page component as a property. It is a legacy API: the official page recommends getStaticProps or getServerSideProps instead. It runs on the server for the initial load and again in the browser on client-side navigations, so it must not use server-only modules and what it returns is serialised, plain objects only. Its context has pathname, query, asPath, req, res and err; req and res exist only on the server. It can only be used in top-level pages/ files, not in nested components, and a page that has it is never statically optimised.

pages/legacy.tsx: getInitialProps and the server/client split (verified)
import type { NextPageContext } from "next";

type Props = { runtime: string; path: string };

export default function Legacy({ runtime, path }: Props) {
  return (
    <main>
      <h1>Legacy page</h1>
      <p>Rendered on the {runtime} for {path}</p>
    </main>
  );
}

Legacy.getInitialProps = async (ctx: NextPageContext): Promise<Props> => {
  return { runtime: ctx.req ? "server" : "client", path: ctx.asPath ?? ctx.pathname };
};

curl http://localhost:3902/legacy returned <p>Rendered on the server for /legacy</p>, and the route table shows ƒ. A client-side navigation to /legacy from next/link would render the same component with runtime equal to client, which is the trap: the same function runs in two places. When getInitialProps is used in _app and the page being navigated to uses getServerSideProps, it runs only on the server.

Automatic Static Optimization

Next.js decides on its own that a page is static when it has no blocking data requirement, which it infers from the absence of getServerSideProps and getInitialProps. That page is prerendered to HTML at build time without you writing a data function; it is still hydrated and fully interactive. The home page of the scratch app (pages/index.tsx, a page with next/head and useRouter but no data function) is shown as ○ in the route table, and next build wrote .next/server/pages/index.html. The official page’s example makes the rule visible: pages/about.js is emitted as .next/server/pages/about.html while adding getServerSideProps turns it into JavaScript (about.js) that renders per request.

The consequences of a static page that you must plan for:

  • The router’s query object is empty during prerendering, because there is no query to provide at build time. After hydration Next.js triggers a second render with the real values, when the page is a dynamic route, has query values in the URL, or when rewrites are configured. Use router.isReady to tell whether query is complete.

  • Dynamic route parameters of a page that uses getStaticProps are always in query.

  • Avoid asPath in the render tree until isReady is true: a statically optimised page only knows asPath on the client, so using it can cause a hydration mismatch.

  • A custom App with getInitialProps turns the optimisation off for all pages without getStaticProps. A custom Document with getInitialProps means you must check ctx.req as described above.

What the home page does with the query (verified: curl "http://localhost:3902/?name=Ada")
import Head from "next/head";
import { useRouter } from "next/router";

export default function Home() {
  const router = useRouter();
  return (
    <main>
      <Head>
        <title>Bookshelf</title>
      </Head>
      <h1>Bookshelf</h1>
      <p>Greeting for: {router.isReady ? (router.query.name ?? "reader") : "reader"}</p>
    </main>
  );
}

The server response for /?name=Ada contained Greeting for: reader and an empty "query":{} in NEXT_DATA: the HTML is the prerendered one, and Ada appears only after hydration. That is the whole point of the optimisation (the file is served from a CDN without running anything) and the whole source of the "flash of default content" on query-driven pages; when the first render must depend on the query, use getServerSideProps instead. The hydration side of this is explained on Hydration and the client-rendered half on Client-Side Rendering.

Features That Exist Only in the Pages Router

Some Pages Router features have no App Router counterpart of the same shape, and a few have none at all. Knowing which is which is the first step of a migration plan.

Table 6. Pages Router features and what the App Router offers instead
Pages Router feature What it does In the App Router

getInitialProps, getStaticProps, getServerSideProps, getStaticPaths

Page-level data functions.

Removed; data is fetched in Server Components. generateStaticParams replaces getStaticPaths.

_app and _document

Page wrapper and HTML skeleton.

One root layout file.

next/head

Per-page head elements.

The Metadata API.

next/router, router.events, isFallback, isReady, asPath, locale fields

Router object.

next/navigation hooks; several fields removed (see the migration table).

Built-in i18n routing (i18n in next.config: locales, defaultLocale, domains)

Locale-prefixed routes, locale detection and domain routing.

Not built in; implement it with a [locale] segment, proxy.ts and a library such as next-intl.

Preview Mode (res.setPreviewData, context.preview)

Render a static page at request time for a CMS draft.

The official page says Draft Mode supersedes it; draftMode() in the App Router. Draft Mode exists in the Pages Router as well (res.setDraftMode).

API routes with req/res

Node-style endpoints.

Route Handlers with the Web Request and Response.

getConfig, serverRuntimeConfig, publicRuntimeConfig

Runtime config.

Removed in Next.js 16 for everyone: use environment variables.

AMP (next/amp, useAmp, the amp config)

AMP pages.

Removed in Next.js 16 for everyone.

next/legacy/image

The pre-13 Image behaviour.

Deprecated in 16; use next/image.

pages/404, pages/500, pages/_error

Error pages.

not-found and error files.

The removals in the last rows are the part of Next.js 16 that hits Pages Router projects specifically: the upgrade guide’s example for the removed publicRuntimeConfig is written for pages/index.tsx, and AMP removal affects pages that used useAmp. The Pages Router i18n configuration and the legacy next-i18next setup are covered with their App Router alternatives on Internationalization; draft mode and previews belong to the Advanced Patterns page, which is written alongside this one. Middleware, now called Proxy, is configured the same way for both routers (see Redirects, Rewrites and Proxy).

Caching: Previous Model and Cache Components

The Pages Router has a short list of caches, none of which cacheComponents changes: the build output of static and getStaticProps pages, the ISR cache (visible through x-nextjs-cache), the Cache-Control headers that you set (and that Next.js sets on ISR responses, as in the output above). Everything else is request-time. To check that, the Pages Router scratch app was built with CACHE_COMPONENTS=1 (which sets cacheComponents: true) and with it unset: the route table was identical in both modes.

Cache Components starts to matter per route that you move. In the migrated copy of the app (below) the new app/books route reads searchParams. With the previous model it built and served as ƒ. With Cache Components the same page failed next build with Route "/books": Next.js encountered uncached or runtime data during prerendering and the message blocking-prerender-dynamic; the fix is to read searchParams inside a component wrapped in Suspense, as the Partial Prerendering and Cache Components and Caching pages explain. So an application can turn cacheComponents on while it still has pages routes, provided the routes already in app follow the new rules. The mapping of getStaticProps and revalidate to caching APIs depends on the model you target; the table below names both.

Incremental Migration to the App Router

The App Router was designed to work alongside the Pages Router: the app directory and the pages directory can both be present, and routes can be moved one at a time. The official migration guide recommends splitting the change into small steps, because moving to the App Router may be the first time you meet Server Components, Suspense and the new data model at once. The order that follows is the guide’s, with the Next.js 16 corrections called out.

flowchart TD A["Upgrade: Next.js 16, React 19,
Node.js 20.9 or later"] --> B["Create app/ and a root layout.tsx
(keep _app and _document)"] B --> C["Move head tags to the Metadata API"] C --> D{"Pick one page
(leaf routes first)"} D --> E["Step 1: copy the page component into a
Client Component with use client"] E --> F["Step 2: add page.tsx, a Server Component,
that renders it"] F --> G["Replace getStaticProps, getServerSideProps and
getStaticPaths with async data, generateStaticParams, dynamicParams"] G --> H["Replace next/router with next/navigation
(or next/compat/router while shared)"] H --> I["Delete the pages file for that route"] I --> J{"More pages left?"} J -->|yes| D J -->|no| K["Move _app providers into layout,
delete _app, _document, _error"] K --> L["Replace pages/api with Route Handlers;
replace middleware naming with proxy"] L --> M["Remove the pages directory and unused config"]
Figure 1. Pages to App migration steps

Before the steps, remember what the two routers share and what they do not:

  • A route must exist in only one place: with app/dup/page.tsx and pages/dup/index.tsx both present, next build failed with App Router and Pages Router both match path: /dup.

  • app/layout.tsx does not wrap pages/ routes, and _app and _document do *not wrap app/* routes. Styles imported in the root layout do not apply to pages; keep _app/_document until the last page has moved. In our migrated copy curl of the app route returned no <nav> (the navigation lives in _app’s layout), while the `pages route still had it.

  • Navigating between a route in app and one in pages is a hard navigation (a full page load), and next/link prefetching does not cross routers. The official guide links to Vercel guidance on optimising these.

  • In a mixed app, an unmatched URL or a notFound() from the Pages Router rendered the App Router’s not-found page, not pages/404.tsx. Without app/not-found.tsx Next.js showed its default "This page could not be found."; with a app/not-found.tsx it showed that file’s content, for both /nope and the pages route /books/99. Add the App Router not-found file on day one of a migration.

The Steps

Step 1: update. The guide’s baseline for the App Router is Next.js 13.4; today you start from 16.3, which brings the Node.js 20.9 requirement, async request APIs and the other changes tabulated below. Upgrade the Pages Router app first, run it, then add app.

Step 2: create app/ and a root layout. A root layout is mandatory, defines <html> and <body> and replaces _app and _document over time. Context providers from _app must become a Client Component imported into the layout.

app/layout.tsx: the minimal root layout (verified)
import type { ReactNode } from "react";

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

Step 3: the getLayout pattern becomes nested layouts, and next/head becomes metadata. A Page.getLayout function is replaced by a layout.tsx in the route’s folder; interactive parts of the old layout component stay in a Client Component and move into the Server Component layout piece by piece. <Head><title> becomes export const metadata or generateMetadata (see Metadata and SEO).

Step 4: migrate one page. Pages in app are Server Components by default, unlike pages, where every page is a Client Component. The guide’s safest route is two sub-steps: move the default-exported page component into a Client Component ('use client'; it is still prerendered to HTML on the first load, like in pages), then add a page.tsx that imports it, and fetch the data in the Server Component instead of in a data function. For our /books search page the result, with the data function gone and searchParams a promise (a Next.js 15 change):

app/books/page.tsx: getServerSideProps replaced by an async Server Component (verified)
import type { Metadata } from "next";
import Link from "next/link";
import SearchBox from "@/components/SearchBox";
import { listBooks } from "@/lib/books";

export const metadata: Metadata = { title: "Books - Bookshelf" };

export default async function BooksPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q = "" } = await searchParams;
  const books = await listBooks(q);
  return (
    <main>
      <h1>Books{q ? ` matching "${q}"` : ""}</h1>
      <SearchBox />
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <Link href={`/books/${b.id}`}>{b.title}</Link> by {b.author}
          </li>
        ))}
      </ul>
    </main>
  );
}

Step 5: routing hooks. The component SearchBox imported useRouter from next/router, which fails in app. The shared version below uses only next/navigation, and it works in both routers: in this experiment Next.js made the App Router hooks work inside pages too (the build succeeded, and the same component rendered, with value="", on the statically optimised home page pages/index.tsx as well as on the app route; router.push was not exercised in a browser). If a component has to keep using router.events, isReady or asPath, use next/compat/router, which returns null in app.

components/SearchBox.tsx: a Client Component using only next/navigation (verified in both routers)
"use client";

import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";

export default function SearchBox() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [q, setQ] = useState(searchParams?.get("q") ?? "");

  function onSubmit(e: FormEvent) {
    e.preventDefault();
    router.push(q ? `/books?q=${encodeURIComponent(q)}` : "/books");
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search books" />
      <button type="submit">Search</button>
    </form>
  );
}

The new useRouter has no pathname, query, asPath, isReady, isFallback, route, basePath or locale fields: use usePathname, useSearchParams and useParams, and loading.tsx or Suspense in place of isFallback. Router events are replaced by combining usePathname and useSearchParams.

Step 6: the build. With app/books/page.tsx added and pages/books/index.tsx deleted, next build printed both routers' tables:

Route tables of the mixed app after migrating one route (from next build)
Route (app)
┌ ○ /_not-found
└ ƒ /books

Route (pages)        Revalidate  Expire
┌ ○ /
├   /_app
├ ○ /404
├ ○ /500
├ ƒ /api/books
├ ƒ /api/books/[id]
├ ƒ /api/revalidate
├   /books/[id]              1m      1y
│ ├ ● /books/1
│ └ ● /books/2
└ ƒ /legacy
Serving the mixed app (results from curl)
$ curl -s "http://localhost:3902/books?q=sol"          # app route
<title>Books - Bookshelf</title> ... <input placeholder="Search books" value="sol"/>
<li><a href="/books/3">Solaris</a> by Stanislaw Lem</li>

$ curl -s http://localhost:3902/books/1                # pages route, still with _app's <nav>
<title data-next-head="">Dune - Bookshelf</title> <nav>...

$ curl -s "http://localhost:3902/api/books?q=dune"     # API route, untouched
[{"id":"1","title":"Dune","author":"Frank Herbert","priceCents":999}]

Step 7: styling. In pages a global stylesheet may only be imported in _app; in app it can be imported in any layout, page or component. With Tailwind CSS v3, add ./app/*/ to the content array of tailwind.config.js, as the guide says (see Styling).

Step 8: finish. When the last page has moved, move the _app providers into a Client Component imported by the root layout, delete _app, _document and _error, replace the remaining pages/api handlers with Route Handlers, and remove the pages-only configuration. Next.js ships codemods for deprecations (for example for next/image and Link), and npx @next/codemod upgrade latest (or next upgrade, added in 16.1) performs version upgrades; both are covered with the full change log on the upgrade page of this section.

Pattern to Replacement

This table maps the Pages Router patterns that the two consulted books rely on (plus the ones the official guide covers) to their App Router replacement. Where the replacement depends on the caching model it names both.

Table 7. Pages Router patterns and their App Router replacements
Pattern in pages (used in the books) App Router replacement Notes

pages/books/[id].js

app/books/[id]/page.tsx

Folder per segment; page is the file that makes it public.

_app with Context or Redux providers

A Client Component that renders the providers, imported in app/layout.tsx

Pages are Server Components by default; providers need 'use client'.

_app global CSS

Import in any layout, page or component

The _app-only restriction is lifted.

_document (<html lang>, <body> classes)

The root layout’s <html> and <body>

beforeInteractive scripts move to the root layout.

Page.getLayout

Nested layout.tsx

Interactive pieces stay in a Client Component.

next/head, next-seo

metadata and generateMetadata

Metadata is declared as data, not rendered as elements.

getServerSideProps

An async Server Component that awaits request data (cookies(), headers(), searchParams)

Previous model: uncached fetch and a Request-time API make it dynamic. Cache Components: wrap in Suspense.

getStaticProps

A Server Component that is prerendered at build time

Previous model: fetch with cache: 'force-cache'. Cache Components: use cache. See the note on the guide below.

getStaticProps with revalidate

fetch with next.revalidate (previous model) or cacheLife (Cache Components)

Time-based ISR.

getStaticPaths

generateStaticParams

Return [{ id: '1' }] instead of { params: { id: '1' } }; usable in layouts.

fallback: false / true / 'blocking'

dynamicParams (false / true)

'blocking' has no equivalent because with streaming the difference from true is negligible.

getInitialProps

Remove it

Fetch in a Server Component instead.

res.revalidate('/path')

revalidatePath or revalidateTag in a Route Handler or Server Action

Protect the trigger as before.

pages/api/*.ts with req, res

app/api/*/route.ts with GET, POST and the Web Request/Response

Prefer Server Components or Server Actions where the API was only called by your own pages.

req.cookies, req.headers in the data function

cookies() and headers() from next/headers, which are async

Read-only.

useRouter from next/router

useRouter, usePathname, useSearchParams, useParams from next/navigation

next/compat/router while shared.

router.events

usePathname and useSearchParams in an effect

No event emitter.

router.isFallback

loading.tsx or Suspense

Streaming replaces the fallback render.

<Link><a>Text</a></Link>

<Link>Text</Link>

Link renders the <a> itself (since Next.js 13; a codemod exists).

pages/404.tsx, pages/500.tsx, pages/_error.tsx

not-found.tsx, error.tsx (and global-error.tsx for the root layout)

error.tsx must be a Client Component.

Preview Mode, previewData

draftMode()

Draft Mode exists in both routers.

i18n config, next-i18next

app/[locale], proxy.ts, next-intl

No built-in i18n routing.

NextAuth.js v4 SessionProvider, useSession, getSession in getServerSideProps

Auth.js auth() in Server Components, route handlers and proxy.ts

Auth.js v5 is still a beta and is now part of Better Auth; see the authentication page.

middleware.ts

proxy.ts

Renamed in Next.js 16; the proxy runs on the Node.js runtime.

next/image (Next.js 10 era)

next/image with remotePatterns

images.domains and next/legacy/image are deprecated.

serverRuntimeConfig, publicRuntimeConfig

Environment variables

Removed in Next.js 16.

Client useEffect + fetch, SWR

The same in a Client Component, or fetch on the server and pass props

See Client-Side Rendering.

The official migration guide’s data-fetching section still comments that fetch() "will default to force-cache`" in the `app directory, and shows { cache: 'no-store' } as the equivalent of getServerSideProps. Both comments predate Next.js 15: today fetch is not cached by default, so an unqualified fetch is already the getServerSideProps equivalent and you must opt in to caching. The Node.js baseline in the guide (18.17) is also outdated.

What Changed Recently

Table 8. Changes that affect a Pages Router application
Version Change

9.3 and 9.5

getStaticProps, getStaticPaths, getServerSideProps introduced (9.3); stable ISR (9.5).

9.5.3

The as argument is no longer needed for dynamic routes (the useRouter page marks it as the pre-9.5.3 way).

10.0

Built-in i18n routing; fallback: 'blocking' and notFound added.

12.1 and 12.2

On-demand ISR added (12.1, beta) and stable (12.2).

13.0

The App Router and the app directory arrive, side by side with pages; next/image replaces the old behaviour (with next/legacy/image for the old one); Link no longer needs a child <a>; next/font.

13.4

App Router stable; the Pages Router data function pages note it.

15

Async request APIs and uncached fetch in the App Router. They do not apply to the Pages Router data functions: the examples above read params and query synchronously on 16.3.8.

16.0

AMP, serverRuntimeConfig and publicRuntimeConfig removed; middleware renamed proxy; Turbopack the default bundler for dev and build (a custom webpack config fails next build unless you opt out with --webpack); next lint removed; next/legacy/image and images.domains deprecated; Node.js 20.9 minimum.

16.x

The Pages Router keeps its own documentation index at https://nextjs.org/docs/pages/llms.txt, versioned with 16.3.8.

This page’s own version-by-version claims come from the 16 upgrade guide and the Pages Router API pages' version history tables; the full 13 to 16.3 change log is on the upgrade page of this section. The practical effect of 16.0 on a Pages project: the build uses Turbopack unless you pass --webpack, so any custom webpack configuration, Babel config or loader must be checked, and next lint is gone in favour of running ESLint directly.

Where the Books Are Outdated

The consulted books are written for the Pages Router (Next.js 12, React 17 and Next.js 13.2, React 18), so almost all of their structure still applies; what has moved is below.

Table 9. Where the books' Pages Router material no longer matches Next.js 16.3
Book pattern Current state

<Link><a>…​</a></Link> and the as prop for dynamic links

Link renders the <a>, and as is no longer needed for dynamic routes. The codemod new-link rewrites old usages.

The Next.js 10 next/image loader and domains config

next/image is the 13+ component; images.domains is deprecated for remotePatterns; next/legacy/image is deprecated.

next-seo and per-page next/head for SEO

Fine on the Pages Router, but the App Router replaces next/head with the Metadata API, which the second book itself notes began in version 13.

next-i18next and the i18n config

Pages Router only; the App Router has no built-in i18n routing.

NextAuth.js v4 patterns (SessionProvider, useSession)

Auth.js v5 is still a beta (next-auth@5.0.0-beta.32) and the project is now part of Better Auth. v4 remains latest on npm (4.24.15), so check which one a guide means.

Preview Mode

Superseded by Draft Mode.

getInitialProps in _app and _document

Legacy; documented as not recommended, and it disables Automatic Static Optimization.

Redux or Context in _app

Still works in pages; in app, providers must live in Client Components.

AMP pages

The AMP APIs and configuration are removed in Next.js 16.

Pitfalls

  • Secrets in props. Everything returned from a data function is serialised into NEXT_DATA; return only what the component needs. Server-only modules imported in a page can end up in the client bundle if they are used in the component, and a package that imports server-only fails the Pages Router build with a message that it is used in the Pages Router.

  • Unprotected revalidation and API routes. res.revalidate and pages/api/* are public endpoints: validate method, token and body, and check authorisation in the handler.

  • The query is empty on static pages. Read it only after router.isReady; if the first render must depend on it, use getServerSideProps.

  • A getInitialProps in _app. It silently turns static optimisation off for the whole site.

  • <title> with several children. It produced an empty title in our run; use one string.

  • Mixed routers. Layouts and global CSS do not cross; the not-found page is the App Router’s; navigations between the routers are hard navigations; the same URL in both is a build error.

  • next/router in app. It is not supported there; use next/navigation or next/compat/router.

  • Moving a route under Cache Components without Suspense. blocking-prerender-dynamic, as shown above.