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.
| Concept | File or export | Runs |
|---|---|---|
Route |
A file in |
Build, request or browser, depending on its data function |
Page-wide wrapper |
|
Server for the first render, browser for every navigation |
HTML skeleton |
|
Server only, never in the browser |
Error pages |
|
Build (404, 500) or request ( |
Endpoint |
A file in |
Request, server only |
Data for a page |
|
Build or request ( |
Navigation |
|
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):
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
Source: Pages Router: Project Structure
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).
| File | URL | Notes |
|---|---|---|
|
|
An |
|
|
Same rule one level down. |
|
|
A dynamic segment; the value is in |
|
|
A catch-all segment; the value is an array. |
|
|
An optional catch-all: also matches the route without a value. |
|
|
Anything in |
|
none |
Reserved files; not reachable as routes. |
|
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.
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:
-
Appdoes not supportgetStaticPropsorgetServerSideProps. Data that every page needs has to be fetched by each page, or you reach forgetInitialPropsonApp. -
getInitialPropsonAppis documented as a pattern the Next.js team does not recommend: it disables Automatic Static Optimization for every page that has nogetStaticProps, and the page points to incremental adoption of the App Router instead. -
If you add
pages/_appwhile 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.
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>
);
}
Source: Pages Router: Custom Document
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
Headfromnext/documentis notnext/head. Use it only for tags shared by every page; usenext/headin pages and components for the rest (titles, per-page meta). The next section of this page coversnext/head. -
React components outside
<Main />are not initialised in the browser, so do not put application logic or custom CSS such asstyled-jsxthere; use a layout in_appfor shared UI. -
Documentdoes not supportgetStaticPropsorgetServerSideProps. -
A
getInitialPropson a classDocumentis used for libraries that need to customiserenderPage(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 thatctx.reqis defined before assuming the page was rendered per request, because it isundefinedfor 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).
Source: Pages Router: Head
_error, 404 and 500
The Pages Router has three levels of error pages, all in pages/:
-
404.tsxis the page shown when no route matches (and when a data function returnsnotFound: 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.tsxis the page shown when a server-side error occurs. It is also generated statically. -
_error.tsxis the advanced hook. 500 errors are handled by theErrorcomponent on both the server and the client, and_erroroverrides it. It is only used in production: in development you get the error overlay with the call stack instead.
// 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;
Source: Pages Router: Custom Errors
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.
Source: Pages Router: Custom Errors
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.
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` });
}
}
Source: Pages Router: API Routes
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, forGET. -
They are affected by
pageExtensions. -
req.bodyis typedanyinNextApiRequestbecause 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):
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");
}
}
$ 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.
| Field | Meaning |
|---|---|
|
The route file’s path after |
|
The query string and the dynamic route parameters as an object. Empty during prerendering when the page does not use server-side rendering. |
|
The path as shown in the browser, including the search params; excludes |
|
Whether the router fields are updated on the client. Use it inside effects, not to render different HTML on the server. |
|
Whether the current page is shown in |
|
Whether the application is in preview mode. |
|
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.
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>
);
}
Source: Pages Router: useRouter
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.
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 |
|
|
|
Context |
|
|
|
Return |
|
|
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.
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:
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:
| Value | Behaviour for an unbuilt path | Replaced in the App Router by |
|---|---|---|
|
A 404 page. Only the paths you returned exist. |
|
|
Serves a fallback version of the page (props empty, |
|
|
The server renders the page on the first request and returns the HTML; no loading state. Subsequent requests are served from the cache. |
|
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.
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>
);
}
Source: Pages Router: getServerSideProps
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.
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 };
};
Source: Pages Router: getInitialProps
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
queryobject 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 whenrewritesare configured. Userouter.isReadyto tell whetherqueryis complete. -
Dynamic route parameters of a page that uses
getStaticPropsare always inquery. -
Avoid
asPathin the render tree untilisReadyistrue: a statically optimised page only knowsasPathon the client, so using it can cause a hydration mismatch. -
A custom
AppwithgetInitialPropsturns the optimisation off for all pages withoutgetStaticProps. A customDocumentwithgetInitialPropsmeans you must checkctx.reqas described above.
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.
| Pages Router feature | What it does | In the App Router |
|---|---|---|
|
Page-level data functions. |
Removed; data is fetched in Server Components. |
|
Page wrapper and HTML skeleton. |
One root |
|
Per-page head elements. |
The Metadata API. |
|
Router object. |
|
Built-in |
Locale-prefixed routes, locale detection and domain routing. |
Not built in; implement it with a |
Preview Mode ( |
Render a static page at request time for a CMS draft. |
The official page says Draft Mode supersedes it; |
API routes with |
Node-style endpoints. |
Route Handlers with the Web |
|
Runtime config. |
Removed in Next.js 16 for everyone: use environment variables. |
AMP ( |
AMP pages. |
Removed in Next.js 16 for everyone. |
|
The pre-13 Image behaviour. |
Deprecated in 16; use |
|
Error pages. |
|
Source: Pages Router: Internationalization, Pages Router: Preview Mode, Pages Router: Draft Mode and Upgrading to version 16
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.
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"]
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.tsxandpages/dup/index.tsxboth present,next buildfailed withApp Router and Pages Router both match path: /dup. -
app/layout.tsxdoes not wrappages/routes, and_appand_documentdo *not wrapapp/*routes. Styles imported in the root layout do not apply to pages; keep_app/_documentuntil the last page has moved. In our migrated copycurlof theapproute returned no<nav>(the navigation lives in_app’s layout), while the `pagesroute still had it. -
Navigating between a route in
appand one inpagesis a hard navigation (a full page load), andnext/linkprefetching 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, notpages/404.tsx. Withoutapp/not-found.tsxNext.js showed its default "This page could not be found."; with aapp/not-found.tsxit showed that file’s content, for both/nopeand thepagesroute/books/99. Add the App Routernot-foundfile 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.
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):
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.
"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>
);
}
Source: The next/compat/router export and useSearchParams
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:
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
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.
Pattern in pages (used in the books) |
App Router replacement | Notes |
|---|---|---|
|
|
Folder per segment; |
|
A Client Component that renders the providers, imported in |
Pages are Server Components by default; providers need |
|
Import in any layout, page or component |
The |
|
The root layout’s |
|
|
Nested |
Interactive pieces stay in a Client Component. |
|
|
Metadata is declared as data, not rendered as elements. |
|
An async Server Component that awaits request data ( |
Previous model: uncached |
|
A Server Component that is prerendered at build time |
Previous model: |
|
|
Time-based ISR. |
|
|
Return |
|
|
|
|
Remove it |
Fetch in a Server Component instead. |
|
|
Protect the trigger as before. |
|
|
Prefer Server Components or Server Actions where the API was only called by your own pages. |
|
|
Read-only. |
|
|
|
|
|
No event emitter. |
|
|
Streaming replaces the fallback render. |
|
|
|
|
|
|
Preview Mode, |
|
Draft Mode exists in both routers. |
|
|
No built-in i18n routing. |
NextAuth.js v4 |
Auth.js |
Auth.js v5 is still a beta and is now part of Better Auth; see the authentication page. |
|
|
Renamed in Next.js 16; the proxy runs on the Node.js runtime. |
|
|
|
|
Environment variables |
Removed in Next.js 16. |
Client |
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
| Version | Change |
|---|---|
9.3 and 9.5 |
|
9.5.3 |
The |
10.0 |
Built-in i18n routing; |
12.1 and 12.2 |
On-demand ISR added (12.1, beta) and stable (12.2). |
13.0 |
The App Router and the |
13.4 |
App Router stable; the Pages Router data function pages note it. |
15 |
Async request APIs and uncached |
16.0 |
AMP, |
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.
| Book pattern | Current state |
|---|---|
|
|
The Next.js 10 |
|
|
Fine on the Pages Router, but the App Router replaces |
|
Pages Router only; the App Router has no built-in i18n routing. |
NextAuth.js v4 patterns ( |
Auth.js v5 is still a beta ( |
Preview Mode |
Superseded by Draft Mode. |
|
Legacy; documented as not recommended, and it disables Automatic Static Optimization. |
Redux or Context in |
Still works in |
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 importsserver-onlyfails the Pages Router build with a message that it is used in the Pages Router. -
Unprotected revalidation and API routes.
res.revalidateandpages/api/*are public endpoints: validate method, token and body, and check authorisation in the handler. -
The
queryis empty on static pages. Read it only afterrouter.isReady; if the first render must depend on it, usegetServerSideProps. -
A
getInitialPropsin_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/routerinapp. It is not supported there; usenext/navigationornext/compat/router. -
Moving a route under Cache Components without Suspense.
blocking-prerender-dynamic, as shown above.
References
-
Real-World Next.js (Packt, 2022), the publisher page, consulted for the Pages Router patterns only
-
Practical Next.js for E-Commerce (Apress, 2023), the publisher page, consulted for the Pages Router patterns only