Next.js NotesRohit’s interview study guide
Chapter 13

The Pages Router

How Next.js worked before the App Router, and still works in a lot of production code: pages/, _app and _document, getServerSideProps, getStaticProps with ISR and fallback modes, API Routes, client-side fetching, and how each piece maps to the App Router when you migrate.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How the Pages Router works

Added

Before Next.js 13 there was one router: every file in pages/ is a route, and the file exports a React component that renders that page. pages/about.tsx is /about, pages/posts/[slug].tsx is /posts/anything, pages/index.tsx is /. There are no layouts, no Server Components and no 'use client': every component in a Pages Router app is an ordinary React component that is rendered to HTML on the server (or at build time) and then hydrated in the browser with all of its JavaScript. In App Router terms, everything is a Client Component.

Data comes in through exactly one door. A page may export one data function: getStaticProps (run at build time), getServerSideProps (run on every request) or the older getInitialProps. Whatever it returns becomes the page component's props. Nothing below the page can fetch on the server: a component deep in the tree gets data only as props passed down from the page, or by fetching in the browser after hydration. Two special files wrap every page: pages/_document.tsx is the HTML skeleton (<html>, <head>, <body>), rendered only on the server; pages/_app.tsx is the component that wraps every page and stays mounted when you navigate, so it is where shared layout, global CSS and providers go.

A mental model that holds up: the Pages Router is a single-page app whose screens each come with an optional server-side props recipe. _document is the envelope (server only, written once per HTML response), _app is the picture frame that stays on the wall, and each page is a picture you swap in and out of the frame. On the first visit the server runs the page's recipe and sends finished HTML with the props embedded as JSON. After that, next/link navigations never fetch HTML again: the browser downloads the next page's JavaScript and, if that page has a data function, a small JSON file with its props, and swaps the picture.

File in pages/URLNotes
index.tsx/blog/index.tsx is /blog
about.tsx/about
posts/[slug].tsx/posts/hellorouter.query.slug / params.slug is "hello"
docs/[...path].tsx/docs/a/bpath is ["a", "b"]; /docs alone is a 404
shop/[[...path]].tsx/shop, /shop/a/boptional catch-all: path is undefined for /shop
api/todos.ts/api/todosan API Route, not a page (see API Routes)
_app.tsx, _document.tsx, _error.tsx, 404.tsx, 500.tsxnonespecial files; the underscore ones are never routes
First load: finished HTML with the props baked in Browser GET /posts/hello Server getServerSideProps returns props Server renders _document, _app and Page to HTML Browser paints HTML, hydrates with __NEXT_DATA__ Client navigation with next/link: JSON only, _app stays mounted Browser click a Link Browser loads page JS, fetches /_next/data/…json Server getServerSideProps returns JSON, no HTML Browser same _app, new Page rendered with props A page with no data function is a plain .html file from the build; navigating to it loads only its JavaScript.
One HTML response per full page load; after that, page JavaScript plus a JSON file of props per navigation.

The example is a small shop. _document sets the <html lang> and a body class, _app wraps every page in a Layout with a navigation bar and a click counter (the counter is there to prove the layout survives navigation), and there are three pages: two with no data function and one with getServerSideProps.

pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en-GB">
      <Head />
      <body className="shop">
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
pages/_app.tsx
import type { AppProps } from "next/app";
import Layout from "../components/Layout";
import "../styles/globals.css";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}
What’s happening
  1. On every full page load the server renders Document once to produce the outer HTML. <Html>, <Head />, <Main /> and <NextScript /> come from next/document and are all required: <Main /> is where the React app's HTML goes, <NextScript /> adds the page's scripts and the __NEXT_DATA__ JSON. Document never runs in the browser, so an onClick here would do nothing.
  2. Inside <Main />, Next.js renders App with two props: Component, the page module that matches the URL (for / that's pages/index.tsx), and pageProps, whatever that page's data function returned ({} when it has none).
  3. App wraps the page in Layout and spreads pageProps onto it. If you forget {...pageProps}, every page silently gets no props: the data functions still run, but their result never reaches the component.
  4. On a client-side navigation only Component and pageProps change. App and Layout are the same component types in the same position, so React keeps them mounted and their state survives. Document isn't involved at all: there is no new HTML.
  5. The global stylesheet is imported in _app because that's the one module every page loads. It's the Pages Router's rule for global CSS (see the gotcha below for when it's enforced).
AdvancedLayout, pages and the build output

The layout, the three pages and the stylesheet:

components/Layout.tsx
import Link from "next/link";
import { useState, type ReactNode } from "react";

export default function Layout({ children }: { children: ReactNode }) {
  const [clicks, setClicks] = useState(0);
  return (
    <>
      <nav>
        <Link href="/">Home</Link>
        <Link href="/about">About</Link>
        <Link href="/posts/hello">A post</Link>
        <button onClick={() => setClicks((c) => c + 1)}>Layout clicks: {clicks}</button>
      </nav>
      <main>{children}</main>
    </>
  );
}
pages/index.tsx
import Head from "next/head";

export default function Home() {
  return (
    <>
      <Head>
        <title>Shop</title>
      </Head>
      <h1>Home</h1>
    </>
  );
}
pages/about.tsx
export default function About() {
  return <h1>About us</h1>;
}
pages/posts/[slug].tsx
import type { GetServerSideProps } from "next";

type Props = { slug: string; renderedAt: string };

export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  return { props: { slug: String(params?.slug), renderedAt: new Date().toISOString() } };
};

export default function Post({ slug, renderedAt }: Props) {
  return (
    <article>
      <h1>Post: {slug}</h1>
      <p>Rendered at {renderedAt}</p>
    </article>
  );
}
styles/globals.css
body { font-family: system-ui, sans-serif; margin: 2rem; }
nav { display: flex; gap: 1rem; align-items: center; }
What’s happening
  1. Layout is an ordinary React component with useState. In the Pages Router that needs no 'use client': every component is shipped to the browser and hydrated.
  2. pages/index.tsx and pages/about.tsx export no data function, so Next.js can render them once at build time. index uses next/head (not the Head from next/document) to set a page-specific <title>.
  3. pages/posts/[slug].tsx exports getServerSideProps, so it can't be prerendered: it runs on every request. params.slug comes from the file name's [slug] segment.
  4. The build output below confirms it: / and /about are ○ (static), /posts/[slug] is ƒ (rendered per request).
next build
Route (app)
─ ○ /_not-found

Route (pages)
┌ ○ /
├   /_app
├ ○ /about
└ ƒ /posts/[slug]

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Two things in that table are worth reading closely. The /_app row has no symbol because _app isn't a page you can visit; it's listed because its JavaScript is shared by every page. And there's a Route (app) section with /_not-found although this app has no app/ pages: the site builds every example in a copy of a project created by create-next-app@16.4.3, which always has app/layout.tsx. That's a realistic setup (most Pages Router apps you meet mid-migration have both folders), but it changes a few details, and I'll point them out as they come up. When I deleted src/app from a copy and rebuilt a Pages-only app, the pages table listed a ○ /404 row instead, and the ● legend read "prerendered as static HTML (uses getStaticProps)" rather than "(uses generateStaticParams)".

AdvancedWhat the browser receives

next start -p 4513, then curl -sD - http://localhost:4513/ (the <head> scripts trimmed):

curl -sD - http://localhost:4513/ (trimmed)
HTTP/1.1 200 OK
X-Powered-By: Next.js
ETag: "g6q82g33r31i1"
Content-Type: text/html; charset=utf-8

<!DOCTYPE html><html lang="en-GB"><head>…<title data-next-head="">Shop</title>…</head>
<body class="shop"><div id="__next"><nav><a href="/">Home</a><a href="/about">About</a>
<a href="/posts/hello">A post</a><button>Layout clicks: <!-- -->0</button></nav>
<main><h1>Home</h1></main></div>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/","query":{},
"buildId":"N3PBxpd5cSbI0DL9VzFto","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]}</script>
</body></html>
What’s happening
  1. lang="en-GB" and class="shop" came from _document, and the <title> from next/head in the page. Everything React rendered sits inside <div id="__next">: that's what <Main /> turns into, and it's the element the browser hydrates.
  2. The nav, button and <h1> are real HTML, so the page is readable before any JavaScript runs. The button says 0 because that's the initial state on the server.
  3. __NEXT_DATA__ is how the server hands its data to the browser: pageProps (empty here), the page's route ("/"), the query and the buildId. When the JavaScript loads, React hydrates #__next using exactly these props, so the first client render matches the HTML.
  4. "autoExport":true is Automatic Static Optimization saying "this HTML was made at build time". For the getServerSideProps page the same script says "gssp":true and carries real props: curl -s localhost:4513/posts/hello showed {"props":{"pageProps":{"slug":"hello","renderedAt":"2026-10-25T18:02:39.463Z"},"__N_SSP":true},"page":"/posts/[slug]","query":{"slug":"hello"},…,"gssp":true,…}.
  5. Anything in pageProps is visible to anyone who views the source. Never return a secret, a full database row or another user's data from a data function "because the component only shows the name".
AdvancedWhat a client-side navigation does

Headless Chrome against the same server: load /, click the layout button three times, set window.marker, then click the "A post" link, then "About", then reload.

headless Chrome (CDP), pages-shell
1. on /           h1 "Home"          button "Layout clicks: 3"
2. click "A post" → /posts/hello     h1 "Post: hello"   p "Rendered at 2026-10-25T18:02:53.096Z"
                    button "Layout clicks: 3"   window.marker "still here"   document.title ""
                    data requests: /_next/data/N3PBxpd5cSbI0DL9VzFto/posts/hello.json?slug=hello
3. click "About"  → /about           h1 "About us"      button "Layout clicks: 3"   marker "still here"
4. location.reload() on /about       button "Layout clicks: 0"   marker null
What’s happening
  1. The counter reached 3 on the home page. After clicking to /posts/hello it still says 3 and window.marker still exists: no new document was loaded, and _app/Layout stayed mounted. Only the page inside them changed.
  2. The only data request was /_next/data/<buildId>/posts/hello.json?slug=hello. That URL runs getServerSideProps on the server and returns just the props as JSON. Querying it with curl returned {"pageProps":{"slug":"hello","renderedAt":"2026-10-25T18:02:39.484Z"},"__N_SSP":true} with Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate.
  3. /about has no data function, so navigating there needed no data request at all, only its JavaScript chunk.
  4. document.title became "" after leaving the home page: next/head tags belong to the page that rendered them and are removed when it unmounts. Each page that cares about its title must set it.
  5. A real reload creates a new document: the counter is back to 0 and the marker is gone. Layout state lives in the browser's memory, not in the URL.
AdvancedTwo rules for _app and _document
AdvancedPer-page layouts with getLayout

_app gives you one layout for the whole site. For different layouts per section (a dashboard with a sidebar, a marketing page without), the documented pattern is a static getLayout property on the page component that _app calls. Because pages that share a layout return the same component type in the same position, React keeps that layout mounted when you move between them.

pages/_app.tsx
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import type { ReactElement, ReactNode } from "react";

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

type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout };

export default function App({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout ?? ((page) => page);
  return getLayout(<Component {...pageProps} />);
}
components/DashboardLayout.tsx
import Link from "next/link";
import { useState, type ReactNode } from "react";

export default function DashboardLayout({ children }: { children: ReactNode }) {
  const [opened, setOpened] = useState(0);
  return (
    <section>
      <nav>
        <Link href="/dashboard">Overview</Link> <Link href="/dashboard/settings">Settings</Link>{" "}
        <Link href="/">Leave</Link>
      </nav>
      <button onClick={() => setOpened((n) => n + 1)}>Sidebar toggles: {opened}</button>
      {children}
    </section>
  );
}
pages/dashboard/index.tsx
import type { ReactElement } from "react";
import DashboardLayout from "../../components/DashboardLayout";
import type { NextPageWithLayout } from "../_app";

const Overview: NextPageWithLayout = () => <h1>Overview</h1>;

Overview.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;

export default Overview;
pages/dashboard/settings.tsx
import type { ReactElement } from "react";
import DashboardLayout from "../../components/DashboardLayout";
import type { NextPageWithLayout } from "../_app";

const Settings: NextPageWithLayout = () => <h1>Settings</h1>;

Settings.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;

export default Settings;
pages/index.tsx
import Link from "next/link";

export default function Home() {
  return (
    <>
      <h1>Home</h1>
      <Link href="/dashboard">Open the dashboard</Link>
    </>
  );
}
next build
Route (app)
─ ○ /_not-found

Route (pages)
┌ ○ /
├   /_app
├ ○ /dashboard
└ ○ /dashboard/settings

○  (Static)  prerendered as static content
headless Chrome (CDP), get-layout
/dashboard           h1 "Overview"   button "Sidebar toggles: 2"   (clicked twice)
→ /dashboard/settings h1 "Settings"   button "Sidebar toggles: 2"
→ /                  h1 "Home"       (no dashboard layout)
→ /dashboard         h1 "Overview"   button "Sidebar toggles: 0"
What’s happening
  1. NextPageWithLayout is NextPage plus an optional getLayout. AppPropsWithLayout tells TypeScript that Component may carry it.
  2. _app asks the page for its layout function and falls back to "no layout" ((page) => page), then calls it with the rendered page. The layout wraps the page instead of _app doing it.
  3. Overview and Settings both return <DashboardLayout>{page}</DashboardLayout>. Moving between them, React sees a DashboardLayout at the root both times, keeps it, and only swaps its child: the counter stays at 2.
  4. Home has no getLayout, so going to / unmounts DashboardLayout. Coming back mounts a fresh one with opened back at 0. That's reconciliation, not a Next.js feature.
  5. The App Router replaces this whole pattern with nested layout.tsx files, which persist the same way without any wiring (see Pages and layouts).
AdvancedThe router in the Pages Router

Navigation uses next/link and the useRouter hook from next/router (the App Router's hook comes from next/navigation and is a different object). From the docs, the parts you'll see most:

next/routerWhat it is
router.pathnamethe page file's route, e.g. /posts/[slug]
router.querypath params and query string merged, e.g. { slug: "hello", tab: "items" }; {} during prerendering of a static page until isReady
router.asPaththe URL as shown in the browser, e.g. /posts/hello?tab=items
router.isReadytrue once query has been filled in on the client
router.isFallbacktrue while a fallback: true page is showing its fallback (see getStaticProps and getStaticPaths)
router.push(url, as?, { shallow, scroll })client-side navigation; shallow: true changes the URL of the current page without re-running its data function
router.replace, router.back, router.reload, router.prefetchas their names say; prefetch works only in production
router.events.on("routeChangeStart" | "routeChangeComplete" | "routeChangeError", …)navigation events, often used in _app for progress bars and analytics

By default a <Link> in the viewport is prefetched in production. The docs are specific about what that includes: for statically generated pages, the page's JavaScript and its JSON data; for getServerSideProps pages, the data is fetched only when the link is clicked. That matches the navigation above, where the post's JSON was requested once, after the click.

One line in _app turns static pages dynamic

This app's pages have no data functions, except /blog, which has getStaticProps. Then someone adds getInitialProps to _app to read a theme cookie. What does next build print for /, /about and /blog?

pages/_app.tsx
import App, { type AppContext, type AppProps } from "next/app";

type Extra = { theme: string };

export default function MyApp({ Component, pageProps, theme }: AppProps & Extra) {
  return (
    <div data-theme={theme}>
      <Component {...pageProps} />
    </div>
  );
}

MyApp.getInitialProps = async (context: AppContext) => {
  const appProps = await App.getInitialProps(context);
  const cookie = context.ctx.req?.headers.cookie ?? "";
  return { ...appProps, theme: cookie.includes("theme=dark") ? "dark" : "light" };
};
pages/index.tsx
export default function Home() {
  return <h1>Home</h1>;
}
pages/about.tsx
export default function About() {
  return <h1>About us</h1>;
}
pages/blog.tsx
export async function getStaticProps() {
  return { props: { posts: ["First post", "Second post"] } };
}

export default function Blog({ posts }: { posts: string[] }) {
  return <ul>{posts.map((p) => <li key={p}>{p}</li>)}</ul>;
}
Show answer

/ and /about become ƒ (rendered per request); /blog stays ●.

next build
Route (app)
─ ○ /_not-found

Route (pages)
┌ ƒ /
├   /_app
├ ƒ /about
└ ● /blog

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. getInitialProps on _app is a blocking data requirement for every page, because _app renders around every page. Automatic Static Optimization only applies to pages with no blocking data requirement, so it switches off.
  2. / and /about were ○ without this change. Now Next.js must run MyApp.getInitialProps for each request (it reads ctx.req, the incoming request), so they are rendered on demand: ƒ.
  3. /blog has getStaticProps, so it is still generated at build time and stays ●. The docs spell out this exception: getInitialProps in _app disables the optimisation "for pages without getStaticProps".
  4. App.getInitialProps(context) must be called and spread in, otherwise the page's own data (pageProps) is lost.
  5. The lesson for code reviews: an innocent-looking _app.getInitialProps (often added for auth or theming) makes a whole site server-rendered and costs you the CDN. Read the cookie on the client, or move that page to getServerSideProps deliberately.
AdvancedWhen you'll meet it, and how to tell

#getServerSideProps

Added

getServerSideProps is the Pages Router's per-request rendering. Export an async function with that name from a page and Next.js calls it on the server for every request to that page, waits for it, and renders the page with what it returned. It has the request in hand (req, cookies, headers, the query string), so it's the tool for anything that depends on who is asking or must be fresh to the second: an account page, an order, search results, a page behind a login.

Think of it as a controller action that runs before the view, like an Express route that loads data and then renders a template. The differences that matter: the "view" is a React component that will also hydrate in the browser, so the controller's return value is serialised to JSON and shipped along; and when the user navigates to the page with next/link, the same function runs again on the server, but this time only its JSON goes over the wire. The function itself, and every module it imports only for server use, is stripped from the browser bundle, so you can query a database directly in it.

It returns exactly one of three shapes: { props } to render, { notFound: true } to send the 404 page, or { redirect: { destination, permanent } } to send a redirect. Its context argument has params (dynamic segments), query (query string plus params), req and res (Node's IncomingMessage with a parsed req.cookies added, and ServerResponse), resolvedUrl, draftMode and, with i18n configured, locale/locales/defaultLocale.

The example is an order page behind a session cookie. lib/orders.ts stands in for a database (each lookup waits 200 ms, like a round trip).

pages/orders/[id].tsx
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { findOrder, type Order } from "../../lib/orders";

export const getServerSideProps = (async ({ params, req, query }) => {
  const user = req.cookies.session; // parsed for you: { session: "rohit" }
  if (!user) {
    return { redirect: { destination: `/login?next=/orders/${params?.id}`, permanent: false } };
  }

  const order = await findOrder(String(params?.id));
  if (!order || order.owner !== user) {
    return { notFound: true };
  }

  return {
    props: {
      order,
      user,
      tab: typeof query.tab === "string" ? query.tab : "summary",
      renderedAt: new Date().toISOString(),
    },
  };
}) satisfies GetServerSideProps<{ order: Order; user: string; tab: string; renderedAt: string }>;

export default function OrderPage({ order, user, tab, renderedAt }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      <h1>Order {order.id}: {order.item}</h1>
      <p>£{order.total} for {user} ({tab} tab)</p>
      <p>Rendered at {renderedAt}</p>
    </main>
  );
}
What’s happening
  1. A request for /orders/1 arrives. Before any React code runs, Next.js calls getServerSideProps with params = { id: "1" }, the request and the parsed query (?tab=items gives query.tab = "items"; query also contains id).
  2. No session cookie means no user, so it returns a redirect. permanent: false sends a 307 (temporary, the method is kept); true would send 308. The page component never renders.
  3. With a cookie, it awaits the "database" (200 ms). An order that doesn't exist or belongs to someone else returns notFound: true, so the response is a 404, and nobody can probe other people's order ids. Authorisation lives here, next to the data, not in the component.
  4. Otherwise it returns props. renderedAt is computed per request, which makes the "every request" behaviour visible.
  5. satisfies GetServerSideProps<…> checks the function's shape while keeping its exact inferred type, and InferGetServerSidePropsType<typeof getServerSideProps> derives the component's props from it, so the component can't drift from what the function returns.
  6. The page renders to HTML on the server; the props are embedded in __NEXT_DATA__; the browser hydrates the same component with the same props. findOrder and lib/orders.ts are not in the browser bundle, because they are only used by getServerSideProps.
AdvancedOrder data, login page and the build
lib/orders.ts
export type Order = { id: string; item: string; total: number; owner: string };

const orders: Order[] = [
  { id: "1", item: "Running shoes", total: 89, owner: "rohit" },
  { id: "2", item: "Rain jacket", total: 120, owner: "rohit" },
  { id: "3", item: "Tent", total: 240, owner: "sam" },
];

export async function findOrder(id: string): Promise<Order | undefined> {
  await new Promise((r) => setTimeout(r, 200)); // pretend this is a database round trip
  return orders.find((o) => o.id === id);
}
pages/login.tsx
export default function Login() {
  return <h1>Please sign in</h1>;
}

The same app has three more pages used further down: prices.tsx (sets Cache-Control), broken.tsx (throws) and dates.tsx (returns a Date). Every page with getServerSideProps is ƒ; the login page has no data function and is ○:

next build
Route (app)
─ ○ /_not-found

Route (pages)
┌ ƒ /broken
├ ƒ /dates
├ ○ /login
├ ƒ /orders/[id]
└ ƒ /prices

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedWhat the server sends

next start -p 4513, then curl with and without the cookie (header lists trimmed to the interesting ones, <!-- --> text separators removed):

curl, gssp-orders
$ curl -sD - -o /dev/null localhost:4513/orders/1
HTTP/1.1 307 Temporary Redirect
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Location: /login?next=/orders/1

$ curl -s -b session=rohit "localhost:4513/orders/1?tab=items"     (twice)
HTTP/1.1 200 OK
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
<main><h1>Order 1: Running shoes</h1><p>£89 for rohit (items tab)</p><p>Rendered at 2026-10-25T18:04:24.980Z</p></main>
HTTP/1.1 200 OK
<main><h1>Order 1: Running shoes</h1><p>£89 for rohit (items tab)</p><p>Rendered at 2026-10-25T18:04:25.203Z</p></main>

$ curl -sD - -o /dev/null -b session=rohit localhost:4513/orders/3     (sam's order)
HTTP/1.1 404 Not Found
$ curl -sD - -o /dev/null -b session=rohit localhost:4513/orders/99
HTTP/1.1 404 Not Found

$ curl -s -o /dev/null -w "%{http_code} ttfb=%{time_starttransfer}\n" -b session=rohit localhost:4513/orders/1
200 ttfb=0.203593
200 ttfb=0.202404
What’s happening
  1. Without the cookie the function returned redirect, and Next.js turned it into a real 307 with a Location header. No HTML for the order was rendered.
  2. Two requests 223 ms apart got two different renderedAt values: the function really runs per request. Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate is Next.js's default for these pages, so no browser or CDN keeps a copy.
  3. Sam's order and a missing order both came back 404 Not Found, indistinguishable from outside. (The 404 page shown is the App Router's built-in not-found page, because the template has an app/ folder; in a Pages-only app it's pages/404.tsx or the default one.)
  4. The time to first byte was about 203 ms both times, the 200 ms "database" plus rendering. That's the defining cost of getServerSideProps: the server can't send a single byte until the function finishes, and every request pays it.
AdvancedThe JSON endpoint behind client navigation

When a user reaches /orders/2 through a <Link>, the browser doesn't ask for HTML. It requests /_next/data/<buildId>/orders/2.json, which runs the same getServerSideProps and returns its result. The three return shapes come back like this (build id IBANAHa801FQLKWASE_Od):

curl localhost:4513/_next/data/IBANAHa801FQLKWASE_Od/orders/<id>.json
with session=rohit, id 2:   {"pageProps":{"order":{"id":"2","item":"Rain jacket","total":120,"owner":"rohit"},"user":"rohit","tab":"summary","renderedAt":"2026-10-25T18:04:25.858Z"},"__N_SSP":true}
no cookie, id 2:            {"pageProps":{"__N_REDIRECT":"/login?next=/orders/2","__N_REDIRECT_STATUS":307},"__N_SSP":true}
with session=rohit, id 99:  {"notFound":true}
What’s happening
  1. Props come back as pageProps, and __N_SSP: true tells the client router this page is server-side rendered, so it must fetch fresh data on every navigation (no reuse).
  2. A redirect can't be an HTTP redirect here (the client asked for JSON), so it's encoded as __N_REDIRECT and __N_REDIRECT_STATUS, and the client router performs the redirect itself.
  3. notFound comes back as {"notFound":true}, and the client renders the 404 page.
  4. The build id in the URL changes with every build, which makes the JSON files safe to cache forever per deployment, and raises the question of what happens to a tab that's still open from the previous one (tested next).

To see what an old tab does after a deploy, I loaded / from the pages-shell app in headless Chrome, then stopped the server and started a second build of the same code (a different build id) on the same port, and clicked "A post":

headless Chrome (CDP): click a Link after a redeploy
page loaded with buildId vlxbrbNaPiURh3OUUFj3v, server now runs E2iZkOeCvA4n2kBLSranc
404 /_next/data/vlxbrbNaPiURh3OUUFj3v/posts/hello.json?slug=hello
200 /posts/hello                                   (a full document request)
200 /_next/static/E2iZkOeCvA4n2kBLSranc/_buildManifest.js  …
result: on /posts/hello, window.marker gone, __NEXT_DATA__.buildId E2iZkOeCvA4n2kBLSranc

The old build id's JSON no longer existed (404), so the router fell back to a full page load of the target URL, which brought the new build's HTML and scripts. Open tabs keep working across deployments, at the price of one hard navigation (and lost client state) on their next click.

Does a Link click run getServerSideProps?

A user is on /orders/1 and clicks <Link href="/orders/2">. Does getServerSideProps run? What travels over the network, and what happens to _app?

Show answer

Yes, it runs on the server, once, for /orders/2. Only JSON travels, and _app stays mounted.

What’s happening
  1. The client router sees /orders/2 matches pages/orders/[id].tsx. It already has that page's JavaScript (same page file as /orders/1), so it needs only data.
  2. The page was built with getServerSideProps, so the router requests /_next/data/<buildId>/orders/2.json?id=2. The browser sends cookies with it, so req.cookies.session is there.
  3. On the server, getServerSideProps runs with params.id = "2". The 200 ms database wait happens again before any response, so the click feels as slow as the function.
  4. The response is the JSON shown above (pageProps plus __N_SSP). No HTML, no _document.
  5. In the browser, _app re-renders with the new pageProps. Component is the same page component, so its own state would survive too (same type, same position); that's why the docs suggest key={router.asPath} or an effect on router.query when per-page state must reset.
AdvancedCaching a server-rendered page

You can override the default Cache-Control from getServerSideProps with res.setHeader, and Next.js passes it through:

pages/prices.tsx
import type { GetServerSideProps } from "next";

export const getServerSideProps: GetServerSideProps<{ price: number; at: string }> = async ({ res }) => {
  res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
  return { props: { price: 42, at: new Date().toISOString() } };
};

export default function Prices({ price, at }: { price: number; at: string }) {
  return <p>Price £{price} (rendered {at})</p>;
}
curl -sD - localhost:4513/prices (headers trimmed)
HTTP/1.1 200 OK
Cache-Control: public, s-maxage=10, stale-while-revalidate=59
What’s happening
  1. res is Node's ServerResponse, so setHeader works as in any Node server, before the body is written.
  2. s-maxage=10 lets a shared cache (a CDN) reuse the response for 10 seconds; stale-while-revalidate=59 lets it serve a stale copy for up to 59 more seconds while it fetches a new one in the background.
  3. next start itself doesn't cache it: the function still runs for every request that reaches Node. The header only helps if a CDN sits in front.
  4. Only do this for responses that are the same for everyone. Setting public on a page that reads a session cookie would let a CDN serve one user's page to another. The docs' advice is to check first whether getStaticProps with revalidate fits better.
AdvancedErrors
pages/broken.tsx
export async function getServerSideProps() {
  throw new Error("Database is down");
}

export default function Broken() {
  return <p>You never see this</p>;
}
curl localhost:4513/broken, and the next start log
HTTP/1.1 500 Internal Server Error
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
<h1 class="next-error-h1" …>500</h1> … <h2 …>Internal Server Error</h2>

⨯ Error: Database is down
    at h (.next/server/chunks/ssr/[root-of-the-server]__1vwhalsn0sym4._.js:1:433)
    …
What’s happening
  1. A thrown error in getServerSideProps becomes a 500 response with the error page (pages/500.tsx if you have one, otherwise the default). The message isn't sent to the browser.
  2. The server log gets the error and stack. It was printed twice per request, but the function ran once: with a counter logged before the throw, one request logged gssp call 1 and then the error twice. In next dev you get the error overlay instead of the 500 page.
  3. For expected failures (a record that doesn't exist, a user without access), return notFound or redirect rather than throwing, so the status code means something.
AdvancedTraps: serialisation and one data function per page
AdvancedWhat it costs, and when to use it
  • Time to first byte is the slowest thing it awaits. Nothing streams: no loading state from the server, no partial HTML. Two independent queries awaited one after the other add up; at least use Promise.all.
  • Every request costs server work, and by default nothing is cached (private, no-cache, no-store). Traffic spikes hit your database directly.
  • All of the page's JavaScript still ships. The function is stripped, but the component, its children and their dependencies hydrate in the browser. Props are sent twice on a first load: rendered into the HTML and again as JSON in __NEXT_DATA__.
  • Use it when the response depends on the request (cookies, headers, user) or must be up to the second, and SEO or a no-JavaScript first paint matters. For public data that changes every few minutes, getStaticProps with revalidate is cheaper and faster. For data only the signed-in user sees, where SEO doesn't matter, a static page plus client-side fetching is another option.

#getStaticProps and getStaticPaths

Added

getStaticProps moves the data fetching to build time. Export it from a page and next build calls it, renders the page, and writes two files: the HTML and a JSON file with the props. In production those files are served as they are, by Node or straight from a CDN, so a request costs almost nothing and the page is as fast as a static file. The function never has the request (no cookies, no headers, no query string), because when it runs there is no request.

Two additions make that practical for real sites. revalidate turns a static page into Incremental Static Regeneration (ISR): after the given number of seconds, the next request still gets the old page, but triggers a regeneration in the background, and the requests after that get the new one. And for dynamic routes like pages/products/[id].tsx, getStaticPaths tells the build which ids to generate, while its fallback setting decides what happens when someone asks for an id that wasn't generated: a 404 (false), a loading shell that fills in (true), or a render on the spot ('blocking').

The mental model is a print run. next build prints copies of each page. revalidate: 10 means "if someone takes a copy that's more than 10 seconds old, hand it over anyway and print a fresh one for the next person" (stale-while-revalidate). getStaticPaths is the list of pages to print up front, and fallback is the policy for a page that's not on the list: refuse, hand out a blank form and fill it in while they wait, or make them wait at the counter until it's printed. Either way, once printed, it joins the pile for everyone after.

The first app has a page generated once at build time, a page with revalidate: 10, and an API Route that triggers regeneration on demand. revalidateReason, a field of the context argument in the 16.4.3 docs, tells the page why it was generated.

pages/isr.tsx
import type { GetStaticProps } from "next";

type Props = { generatedAt: string; reason: string };

export const getStaticProps: GetStaticProps<Props> = async ({ revalidateReason }) => {
  return {
    props: { generatedAt: new Date().toISOString(), reason: revalidateReason ?? "unknown" },
    revalidate: 10, // seconds: a request after this may trigger a background regeneration
  };
};

export default function Isr({ generatedAt, reason }: Props) {
  return <p>Generated at {generatedAt} (reason: {reason})</p>;
}
What’s happening
  1. During next build, Next.js calls getStaticProps once. revalidateReason is "build", generatedAt is the build moment, and the result is written to .next/server/pages/isr.html and isr.json.
  2. revalidate: 10 is stored with the page. For the first 10 seconds every request is served the stored copy (x-nextjs-cache: HIT).
  3. The first request after 10 seconds is still served the stored copy (STALE), immediately, and Next.js runs getStaticProps again in the background, this time with revalidateReason: "stale".
  4. When that finishes, the new HTML and JSON replace the old ones, and the next request gets them (HIT again, new generatedAt). If the background run throws, the docs say the old page stays and the next request retries.
  5. No request ever waits for getStaticProps here: the cost of regeneration is paid in the background. The price is that one visitor (the one who triggered it) sees data up to "10 seconds plus however long nobody visited" old.
AdvancedThe build-time page, revalidate API and build
pages/build-time.tsx
import type { GetStaticProps } from "next";

type Props = { generatedAt: string };

export const getStaticProps: GetStaticProps<Props> = async () => {
  return { props: { generatedAt: new Date().toISOString() } };
};

export default function BuildTime({ generatedAt }: Props) {
  return <p>Generated at {generatedAt}</p>;
}
pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.query.secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ message: "Invalid token" });
  }
  try {
    await res.revalidate("/isr"); // the real path, not the file name
    return res.json({ revalidated: true });
  } catch {
    return res.status(500).send("Error revalidating");
  }
}
.env
REVALIDATE_SECRET=demo-secret
next build
Route (app)
─ ○ /_not-found

Route (pages)        Revalidate  Expire
┌ ƒ /api/revalidate
├ ● /build-time
└ ● /isr                    10s      1y

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
What’s happening
  1. Both pages are ● (SSG): prerendered with data at build time. /isr has Revalidate 10s; /build-time has nothing in that column, so it never regenerates until the next build.
  2. Expire 1y is the default upper bound: the build turns it into the stale-while-revalidate value in the Cache-Control header below (31,536,000 seconds is 365 days).
  3. The ● legend says "uses generateStaticParams" even for getStaticProps pages; that's the template's app/ folder showing through. In the Pages-only copy it said "uses getStaticProps".
  4. The API Route is ƒ, like every API Route: they always run per request.
AdvancedISR, measured

The build finished at 18:05:58 UTC; the files it wrote held "generatedAt":"2026-10-25T18:05:57.918Z","reason":"build" (/isr) and "generatedAt":"2026-10-25T18:05:57.937Z" (/build-time). Then next start -p 4513 and this sequence of curls, with the gaps shown in the clock times:

curl -sD - localhost:4513/... (time, cache header, body)
GET /build-time  →  Cache-Control: s-maxage=31536000
GET /isr         →  Cache-Control: s-maxage=10, stale-while-revalidate=31535990

18:05:59  GET /build-time  x-nextjs-cache: HIT    Generated at 2026-10-25T18:05:57.937Z
18:05:59  GET /build-time  x-nextjs-cache: HIT    Generated at 2026-10-25T18:05:57.937Z
18:05:59  GET /isr         x-nextjs-cache: HIT    Generated at 2026-10-25T18:05:57.918Z (reason: build)
18:06:00  GET /isr         x-nextjs-cache: HIT    Generated at 2026-10-25T18:05:57.918Z (reason: build)
18:06:05  GET /isr         x-nextjs-cache: HIT    Generated at 2026-10-25T18:05:57.918Z (reason: build)
18:06:11  GET /isr         x-nextjs-cache: STALE  Generated at 2026-10-25T18:05:57.918Z (reason: build)
18:06:12  GET /isr         x-nextjs-cache: HIT    Generated at 2026-10-25T18:06:11.677Z (reason: stale)

GET /api/revalidate?secret=wrong        →  {"message":"Invalid token"}  (401)
GET /api/revalidate?secret=demo-secret  →  {"revalidated":true}  (200)
18:06:12  GET /isr         x-nextjs-cache: HIT    Generated at 2026-10-25T18:06:12.722Z (reason: on-demand)
GET /_next/data/<buildId>/isr.json  →  x-nextjs-cache: HIT
{"pageProps":{"generatedAt":"2026-10-25T18:06:12.722Z","reason":"on-demand"},"__N_SSG":true}
The measured run: revalidate: 10 (seconds after the build) fresh: build copy stale fresh 0 s 4 s 8 s 10 s 12 s 16 s HIT HIT HIT STALE: old copy sent, regeneration starts HIT: new copy built 18:05:57.9 regenerated 18:06:11.677 Request times are curl's whole-second clock times, placed on the scale by hand.
Stale-while-revalidate: nobody waits; the visitor after the stale one gets the new page.
What’s happening
  1. /build-time gets s-maxage=31536000 (a year) and the same generatedAt forever: it was printed once. Only a new next build changes it.
  2. /isr gets s-maxage=10, stale-while-revalidate=31535990, so a CDN in front behaves the same way Next.js does. 31,535,990 is one year minus the 10 seconds.
  3. The requests 1, 2 and 7 seconds after the build are HITs with the build copy. Nothing ran.
  4. At 18:06:11, about 13 seconds after the build, the request is STALE: it still received the build copy (reason build) but started a regeneration, which stamped 18:06:11.677Z. The very next request is a HIT with that new copy and reason stale.
  5. On-demand: the wrong secret is refused with 401; the right one makes res.revalidate("/isr") regenerate the page immediately, so the next request shows reason on-demand. The data JSON used by client navigation is regenerated along with the HTML. This is how a CMS webhook publishes an edit without waiting out a timer (and why a long revalidate, or none, plus on-demand revalidation is the usual production setup).
AdvancedTesting ISR, and its limits
AdvancedgetStaticPaths and the three fallback modes

For a dynamic route, getStaticProps alone isn't enough: the build has to know which [id]s to generate. getStaticPaths returns them as paths: [{ params: { id: "1" } }, …] (values must be strings, and they're case-sensitive), plus a fallback mode. To compare the modes, the same product page is written three times with the same getStaticPaths list (only id 1). The catalogue has ids 1 to 4; 99 doesn't exist.

lib/products.ts
export type Product = { id: string; name: string };

const catalogue: Record<string, string> = { "1": "Kettle", "2": "Toaster", "3": "Blender", "4": "Grill" };

export async function getProduct(id: string): Promise<Product | null> {
  await new Promise((r) => setTimeout(r, 20)); // a CMS call
  return catalogue[id] ? { id, name: catalogue[id] } : null;
}
pages/false/[id].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { getProduct, type Product } from "../../lib/products";

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [{ params: { id: "1" } }],
  fallback: false,
});

export const getStaticProps: GetStaticProps<{ product: Product }> = async ({ params }) => {
  const product = await getProduct(String(params?.id));
  return product ? { props: { product } } : { notFound: true };
};

export default function Page({ product }: { product: Product }) {
  return <h1>{product.name} (fallback: false)</h1>;
}
pages/blocking/[id].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { getProduct, type Product } from "../../lib/products";

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [{ params: { id: "1" } }],
  fallback: "blocking",
});

export const getStaticProps: GetStaticProps<{ product: Product }> = async ({ params }) => {
  const product = await getProduct(String(params?.id));
  return product ? { props: { product } } : { notFound: true };
};

export default function Page({ product }: { product: Product }) {
  return <h1>{product.name} (fallback: blocking)</h1>;
}
next build
Route (app)
─ ○ /_not-found

Route (pages)
┌   /blocking/[id]
│ └ ● /blocking/1
└   /false/[id]
  └ ● /false/1

○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)

The fallback: true version is a separate app with its catalogue inlined (it was split off while I was measuring build timings; the behaviour doesn't depend on it). One thing its build does that the other two don't: it also renders a fallback shell for /true/[id], which is extra build work, and in a few builds that row was the one that crossed the 300 ms threshold above which next build prints a page's duration (/true/[id] (408 ms)).

pages/true/[id].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";

type Product = { id: string; name: string };
const catalogue: Record<string, string> = { "1": "Kettle", "2": "Toaster", "3": "Blender", "4": "Grill" };

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [{ params: { id: "1" } }],
  fallback: true,
});

export const getStaticProps: GetStaticProps<{ product: Product }> = async ({ params }) => {
  await new Promise((r) => setTimeout(r, 20)); // the same CMS call
  const name = catalogue[String(params?.id)];
  return name ? { props: { product: { id: String(params?.id), name } } } : { notFound: true };
};

export default function Page({ product }: { product?: Product }) {
  const router = useRouter();
  if (router.isFallback) return <h1>Loading product…</h1>; // props are {} in the fallback
  return <h1>{product!.name} (fallback: true)</h1>;
}
next build
Route (app)
─ ○ /_not-found

Route (pages)
─   /true/[id]
  └ ● /true/1

○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)
ls .next/server/pages/true (fallback-true, after next build)
1.html  1.json  1.meta  [id].html  [id].meta  [id].js  …
What’s happening
  1. All three modes generated /…/1 at build time (● with 1.html and 1.json). The difference is only what happens for ids that weren't listed.
  2. fallback: true also produced [id].html: the fallback shell, rendered at build time with empty props and router.isFallback === true. Its <h1> is "Loading product…" and its __NEXT_DATA__ has "pageProps":{} and "isFallback":true. That's why the component must handle missing props: product is undefined in the shell.
  3. notFound: true from getStaticProps is how all three modes reject an id that turns out not to exist at request time. With fallback: false it's only needed if listed paths can disappear (the docs say it isn't needed otherwise).
  4. Next, the requests. Each server was started fresh after its build.
curl -s -D - localhost:4513/... (status, cache header, time to first byte, <h1>)
fallback: false
GET /false/1      200  HIT    0.007 s  Kettle (fallback: false)
GET /false/2      404  HIT    0.002 s  (the 404 page)

fallback: 'blocking'
GET /blocking/1   200  HIT    0.003 s  Kettle (fallback: blocking)
GET /blocking/2   200  MISS   0.029 s  Toaster (fallback: blocking)
GET /blocking/2   200  HIT    0.001 s  Toaster (fallback: blocking)
GET /blocking/3   200  MISS   0.024 s  Blender (fallback: blocking)
GET /blocking/99  404  HIT    0.023 s  (the 404 page)
GET /blocking/99  404  HIT    0.002 s  (the 404 page)

fallback: true
GET /true/1       200  HIT    0.007 s  Kettle (fallback: true)
GET /true/2       200  MISS   0.003 s  Loading product…   "isFallback":true
GET /true/2       200  MISS   0.003 s  Loading product…   (2 s later: still the shell)
GET /true/99      200  MISS   0.002 s  Loading product…   (a product that doesn't exist: 200!)
GET /_next/data/<buildId>/true/4.json   200 MISS  {"pageProps":{"product":{"id":"4","name":"Grill"}},"__N_SSG":true}
GET /true/4       200  HIT    0.001 s  Grill (fallback: true)
GET /_next/data/<buildId>/true/98.json  404 MISS  {"notFound":true}

fallback: true, with a Googlebot User-Agent
GET /true/3       200  MISS   0.023 s  Blender (fallback: true)    "isFallback":false
GET /true/3       200  HIT    0.001 s  Blender (fallback: true)
What’s happening
  1. false: only listed paths exist. /false/2 is a 404 even though product 2 is real; adding products means rebuilding. Use it for small, closed sets (docs pages, a fixed list of categories).
  2. 'blocking': /blocking/2 wasn't built, so the first request ran getStaticProps while the visitor waited (MISS, 29 ms here, mostly the 20 ms CMS call) and got the full HTML, like SSR. The result was cached: the second request is a 1 ms HIT. A missing id gets a real 404 (the second one came back in 2 ms instead of 23). From the outside it looks like a server-rendered page that's only slow once per path.
  3. true for a normal client: the first response is instant, but it is the shell ("Loading product…", isFallback: true), with status 200. Curl, which runs no JavaScript, kept getting the shell: two seconds later /true/2 was still MISS with the shell. The docs say Next.js generates the page "in the background" after serving the shell; in 16.4.3 that didn't happen for the HTML request (I re-ran it in a second build: /true/2 was still the MISS shell on the third request). The page was generated when its JSON was requested (the request the browser makes after loading the shell): after /_next/data/…/true/4.json, /true/4 was a HIT with full HTML.
  4. true and a missing id: /true/99 answered 200 with the loading shell, and only the JSON request said 404. A client without JavaScript, or a monitoring tool checking status codes, sees a successful page for a product that doesn't exist.
  5. true with a crawler: with Googlebot's User-Agent the first request waited (23 ms) and got the full HTML with isFallback:false, exactly like 'blocking', and a missing id (/true/97) was a real 404 for it. The docs describe this: crawlers such as Google are never served a fallback. So the shell is a UX choice for humans, not an SEO risk; the risk is the 200 for missing ids.

What a browser does with fallback: true, recorded in headless Chrome with a MutationObserver on the <h1> (times from navigation start):

headless Chrome (CDP), fallback-true
/true/2:   1 ms "Loading product…"   →  71 ms "Toaster (fallback: true)"
           network: 200 /true/2, then /_next/data/dJylVjHqA5sqo_1BjE61j/true/2.json?id=2 requested twice (200, 200)
/true/99:  1 ms "Loading product…"   →  35 ms "404"
           network: 200 /true/99, then /_next/data/dJylVjHqA5sqo_1BjE61j/true/99.json?id=99 requested twice (404, 404)

The shell painted immediately, the browser fetched the props as JSON, and React swapped the shell for the real page 71 ms later. (The docs mention that several requests for the same JSON are expected with ISR; here there were two.) For a missing id the swap is to the 404 page, while the document's status stays 200. The docs say a next/link navigation to an ungenerated fallback: true path never shows the shell; it waits for the JSON, like 'blocking'.

fallback: falsefallback: truefallback: 'blocking'
Unlisted path, first request404200 with the shell, then JSON fills it inwaits, full HTML
Unlisted path that doesn't exist404200 shell, then client shows 404404
Later requests404full HTML from cachefull HTML from cache
Crawlers404full HTML (treated as blocking; a missing id is a real 404)full HTML
Component must handle isFallbacknoyesno
Use forsmall, closed setshuge catalogues where an instant skeleton beats a waithuge catalogues, SEO-sensitive, simplest code
output: 'export'yesnono
The product that was deleted

pages/products/[id].tsx uses fallback: true. Product 7 was never generated and has been deleted from the CMS. A health checker requests https://shop.example/products/7 with curl and alerts on non-2xx responses. What does it see, and what does a user see?

Show answer

The checker sees 200 and the "Loading…" shell, every time. The user sees the shell for a moment and then the 404 page, at the same URL.

What’s happening
  1. Product 7 isn't in getStaticPaths, so with fallback: true the server answers the first request with the prebuilt shell, [id].html, immediately, status 200. It doesn't know yet that product 7 is gone.
  2. curl runs no JavaScript, so it never requests /_next/data/<buildId>/products/7.json. In my test the shell was served again and again (/true/99 stayed MISS with the shell), so the checker never sees anything else.
  3. A browser runs the page's JavaScript, which requests the JSON. getStaticProps returns notFound: true, the JSON endpoint answers 404 {"notFound":true}, and the client router renders the 404 page in place.
  4. Fixes: use fallback: 'blocking' (the first request waits and gets a real 404), or make the check use a crawler's User-Agent (crawlers get blocking behaviour), or monitor the JSON endpoint.
AdvancedReading files: process.cwd(), not __dirname

#API Routes

Added

API Routes are the Pages Router's backend endpoints: any file under pages/api/ becomes an endpoint at /api/…, compiled into a server-only bundle that never reaches the browser. The file default-exports one function, handler(req, res), that receives Node's IncomingMessage and ServerResponse with a few Express-style helpers added. If you've written Express, you already know them: req.query, req.body, req.cookies, res.status(), res.json(), res.send(), res.redirect(), res.setHeader().

The mental model: an Express route handler that the file system mounts for you, one file per path, with no router object and no middleware chain. One function handles every HTTP method, so you branch on req.method yourself. The body is parsed before your function runs (JSON, form-encoded, text), up to 1 MB by default. Dynamic segments work as for pages (pages/api/todos/[id].ts), and their values arrive in req.query together with the query string.

They exist for the things a browser or another system calls: form posts, client-side data fetching, webhooks, on-demand revalidation (res.revalidate, used in the previous topic). They're still fully supported in Next.js 16, and the App Router's equivalent is a Route Handler (app/**/route.ts), which uses the Web Request/Response API instead. This app has both, side by side, on a shared in-memory store.

pages/api/todos/index.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { addTodo, listTodos, type Todo } from "../../../lib/todos";

type Error = { error: string };

export default function handler(req: NextApiRequest, res: NextApiResponse<Todo[] | Todo | Error>) {
  if (req.method === "GET") {
    return res.status(200).json(listTodos());
  }
  if (req.method === "POST") {
    const title = req.body?.title; // body already parsed from JSON by Next.js
    if (typeof title !== "string" || title.trim() === "") {
      return res.status(400).json({ error: "title must be a non-empty string" });
    }
    return res.status(201).json(addTodo(title.trim()));
  }
  res.setHeader("Allow", "GET, POST");
  return res.status(405).json({ error: `Method ${req.method} not allowed` });
}
What’s happening
  1. pages/api/todos/index.ts is served at /api/todos (index maps to the folder, as for pages). Every method hits this one function.
  2. GET returns the list with res.status(200).json(…), which sets Content-Type: application/json; charset=utf-8 and ends the response.
  3. POST reads req.body.title. Next.js has already parsed the body according to its Content-Type, so for JSON it's an object. NextApiRequest's body is typed any, so the handler validates the shape at runtime before using it; a missing or blank title gets a 400.
  4. A successful create answers 201 with the new todo. The return in front of each res.… call matters: without it, execution would fall through to the 405 code and try to send a second response.
  5. Anything else gets 405 with an Allow header, which is what HTTP expects. Next.js doesn't do this for you in an API Route (it does in a Route Handler, below).
AdvancedThe todo store, the [id] route, the Route Handler
lib/todos.ts
export type Todo = { id: number; title: string; done: boolean };

// One array per server process: fine for a demo, wrong for several instances or serverless.
const todos: Todo[] = [{ id: 1, title: "Buy milk", done: false }];
let nextId = 2;

export const listTodos = () => todos;
export const findTodo = (id: number) => todos.find((t) => t.id === id);
export function addTodo(title: string): Todo {
  const todo = { id: nextId++, title, done: false };
  todos.push(todo);
  return todo;
}
pages/api/todos/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { findTodo } from "../../../lib/todos";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const id = Number(req.query.id); // dynamic segment and query string share req.query
  const todo = findTodo(id);
  if (!todo) return res.status(404).json({ error: `No todo ${req.query.id}` });
  res.status(200).json({ ...todo, debug: req.query.debug ?? null });
}
app/api/v2/todos/route.ts
import { addTodo, listTodos } from "../../../../lib/todos";

export async function GET() {
  return Response.json(listTodos());
}

export async function POST(request: Request) {
  const body = await request.json().catch(() => null); // you parse the body yourself
  const title = body?.title;
  if (typeof title !== "string" || title.trim() === "") {
    return Response.json({ error: "title must be a non-empty string" }, { status: 400 });
  }
  return Response.json(addTodo(title.trim()), { status: 201 });
}

The app also has pages/api/webhook.ts and pages/api/forgot.ts, shown further down. Every API Route is ƒ, and with Cache Components on, so is the Route Handler:

next build
Route (app)
┌ ○ /_not-found
└ ƒ /api/v2/todos

Route (pages)
┌ ƒ /api/forgot
├ ƒ /api/todos
├ ƒ /api/todos/[id]
└ ƒ /api/webhook

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedA curl session against both

next start -p 4513, one server, these requests in this order (date, connection and length headers removed):

curl -s -i …
$ curl localhost:4513/api/todos
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[{"id":1,"title":"Buy milk","done":false}]

$ curl -X POST -H 'Content-Type: application/json' -d '{"title":"Write chapter 13"}' localhost:4513/api/todos
HTTP/1.1 201 Created
{"id":2,"title":"Write chapter 13","done":false}

$ curl -X POST -H 'Content-Type: application/json' -d '{"title":""}' localhost:4513/api/todos
HTTP/1.1 400 Bad Request
{"error":"title must be a non-empty string"}

$ curl -X POST -d 'title=From a form' localhost:4513/api/todos
HTTP/1.1 201 Created
{"id":3,"title":"From a form","done":false}

$ curl -X DELETE localhost:4513/api/todos
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST
{"error":"Method DELETE not allowed"}

$ curl 'localhost:4513/api/todos/2?debug=yes'
HTTP/1.1 200 OK
{"id":2,"title":"Write chapter 13","done":false,"debug":"yes"}

$ curl localhost:4513/api/todos/99
HTTP/1.1 404 Not Found
{"error":"No todo 99"}

$ curl localhost:4513/api/v2/todos
HTTP/1.1 200 OK
content-type: application/json
Transfer-Encoding: chunked
[{"id":1,"title":"Buy milk","done":false}]

$ curl -X POST -H 'Content-Type: application/json' -d '{"title":"Via a Route Handler"}' localhost:4513/api/v2/todos
HTTP/1.1 201 Created
{"id":2,"title":"Via a Route Handler","done":false}

$ curl -X POST -d 'not json' localhost:4513/api/v2/todos
HTTP/1.1 400 Bad Request
{"error":"title must be a non-empty string"}

$ curl -X DELETE localhost:4513/api/v2/todos
HTTP/1.1 405 Method Not Allowed
What’s happening
  1. The API Route behaves like an Express app: JSON in, JSON out, the status codes the handler chose, and its own Allow header on the 405.
  2. -d 'title=From a form' sends application/x-www-form-urlencoded, and req.body.title still worked: the built-in body parser handles form posts too, which is why a plain HTML <form method="post" action="/api/todos"> works against an API Route.
  3. /api/todos/2?debug=yes shows req.query holding both the segment (id: "2") and the query string (debug: "yes"). Both are strings (or string arrays), never numbers, hence Number(req.query.id).
  4. The Route Handler answered GET and POST because it exports those two functions, and Next.js answered DELETE with a 405 by itself because there's no DELETE export. The body isn't parsed for you: request.json() threw on not json, the .catch turned that into null, and the validation said 400.
  5. The surprise: the Route Handler listed only "Buy milk" right after the API Route had created todos 2 and 3, and its own POST got id 2 again. lib/todos.ts is imported by both routers, but the Pages bundle and the App bundle each got their own copy of the module, with its own todos array and nextId. Module-level state is never a database, and during a migration the two routers don't even share it within one process.
AdvancedWebhooks: turning the body parser off

Signed webhooks (Stripe, GitHub) sign the exact bytes of the body. If Next.js parses the JSON first, you can't reproduce those bytes, so the route exports config to disable the parser and reads the raw stream itself:

pages/api/webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import type { NextApiRequest, NextApiResponse } from "next";

export const config = { api: { bodyParser: false } }; // we need the exact bytes that were signed

async function readRawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    return res.status(405).end();
  }
  const raw = await readRawBody(req);
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "").update(raw).digest("hex");
  const given = String(req.headers["x-signature"] ?? "");
  const valid = given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!valid) return res.status(401).json({ error: "bad signature" });

  const event = JSON.parse(raw.toString("utf8"));
  return res.status(200).json({ received: event.type, bytes: raw.length, bodyWas: typeof req.body });
}
.env
WEBHOOK_SECRET=whsec_demo
curl against /api/webhook (signature made with openssl dgst -sha256 -hmac whsec_demo)
$ curl -X POST -H 'x-signature: fc99391209555a0109d758b8fe4bb681312bc22e1005d6eb22195785e2eb7da6' -d '{"type":"order.paid","id":42}' /api/webhook
{"received":"order.paid","bytes":29,"bodyWas":"undefined"}  (200)
$ curl -X POST -H 'x-signature: wrong' …
{"error":"bad signature"}  (401)
$ curl -X POST (the same signature, the body with one extra space)
{"error":"bad signature"}  (401)
$ curl -i /api/webhook
HTTP/1.1 405 Method Not Allowed
Allow: POST
What’s happening
  1. export const config = { api: { bodyParser: false } } is read by Next.js at build time for this one route. req.body is then left alone: the response shows bodyWas: "undefined".
  2. req is a Node readable stream, so for await collects the raw chunks; the 29 bytes are exactly what curl sent.
  3. The HMAC of those bytes with the shared secret is compared with the header using timingSafeEqual (after a length check, because it throws on different lengths), so the comparison time doesn't leak how many characters matched.
  4. Changing the body by one space with the old signature fails: that's the property the signature gives you, and it only works because the bytes weren't re-serialised.
  5. The other config.api options from the docs: bodyParser: { sizeLimit: "500kb" } (default 1mb), responseLimit (a warning above 4 MB by default, or false), externalResolver: true (when something like Express sends the response, to silence the "resolved without sending a response" warning), and a top-level maxDuration for platforms that limit function time.
AdvancedTwo classic API Route mistakes
AdvancedAPI Routes and Route Handlers compared
API Route (pages/api/x.ts)Route Handler (app/x/route.ts)
Signaturedefault function handler(req, res)named exports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Request/responseNode IncomingMessage/ServerResponse plus helpersWeb Request/Response (NextRequest/NextResponse add cookies, nextUrl)
Bodyparsed for you (JSON, form, text); config.api.bodyParseryou call request.json(), .formData(), .text()
Unsupported methodyou send the 405Next.js answers 405
Cachingnever; always runs per requestdynamic by default; a GET can be cached with 'use cache' (see Caching and Route Handlers)
Streamingres.write / res.endreturn a ReadableStream
output: 'export'not supporteda GET that can be prerendered is exported
Same path in app/ and pages/build error: "App Router and Pages Router both match path: /api/todos"(same error)

#Data fetching on the client in the Pages Router

Added

Everything so far fetched data before HTML was produced. The third option is to send a page without that data and fetch it in the browser, after hydration. In the Pages Router this is the only way a component below the page can get its own data: data functions exist only at page level, so a widget that wants fresh data either receives it as props from the page or fetches it itself in an effect. It's also the right choice for data that's private to the signed-in user and has no SEO value (a dashboard, notifications, a cart badge), for data that changes every few seconds, and for anything that depends on browser-only state.

The common shape is a static shell plus client fetching: the page has no data function, so it's prerendered once at build time and served from a CDN, and an effect fills in the user-specific part. You get the fast, cacheable first paint of a static page, and the personal data costs one API call. The price is a loading state the user sees, and HTML without the data (search engines and link previews see "Loading…").

One Pages Router detail catches everyone: on a statically optimised page, router.query is {} in the prerendered HTML and during hydration, because the build didn't know the query string. Next.js fills it in right after hydration and sets router.isReady to true. Reading router.query.q on the first render gets undefined; fetching with it fetches the wrong thing.

pages/search.tsx
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

type State = { status: "idle" | "loading" | "done" | "error"; results: string[] };

export default function Search() {
  const router = useRouter();
  const q = typeof router.query.q === "string" ? router.query.q : "";
  const [state, setState] = useState<State>({ status: "idle", results: [] });

  useEffect(() => {
    if (!router.isReady || !q) return; // query is {} until the router is ready
    const controller = new AbortController();
    setState({ status: "loading", results: [] });
    fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
      .then((results: string[]) => setState({ status: "done", results }))
      .catch((e) => { if (e.name !== "AbortError") setState({ status: "error", results: [] }); });
    return () => controller.abort(); // a newer query cancels the older request
  }, [router.isReady, q]);

  return (
    <main>
      <h1>{router.isReady ? `Results for "${q}"` : "Search"}</h1>
      <p id="status">{state.status}</p>
      <ul>{state.results.map((r) => <li key={r}>{r}</li>)}</ul>
    </main>
  );
}
What’s happening
  1. The page exports no data function, so it's prerendered at build time with router.query = {}: q is "", isReady is false, the heading is "Search" and the status idle.
  2. In the browser, hydration renders the same thing (it must match the HTML). Then the router fills in query from the URL and sets isReady, which re-renders with q = "lamp".
  3. The effect depends on [router.isReady, q], so it runs again. Now it's ready and has a query: it sets loading, starts the fetch with an AbortController, and returns a cleanup that aborts it.
  4. When the response arrives, status becomes done with the results. If q changes before that (the user types, or router.push changes the query), React runs the cleanup first, aborting the old request, then starts a new one. The AbortError from the aborted fetch is ignored, so an old response can never overwrite a newer one.
  5. r.ok ? r.json() : Promise.reject(…) matters because fetch only rejects on network failure; a 500 would otherwise be parsed as results.
AdvancedThe search API and the build output
pages/api/search.ts
import type { NextApiRequest, NextApiResponse } from "next";

const products = ["Desk lamp", "Floor lamp", "Lamp shade", "Desk chair"];

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  await new Promise((r) => setTimeout(r, 300)); // a slow search service
  const q = String(req.query.q ?? "").toLowerCase();
  res.setHeader("Cache-Control", "no-store");
  res.status(200).json(products.filter((p) => p.toLowerCase().includes(q)));
}
next build
Route (app)
─ ○ /_not-found

Route (pages)
┌ ƒ /api/search
└ ○ /search

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedWhat the HTML says, and what the browser then does

curl -s "localhost:4513/search?q=lamp" (separators removed):

curl -s "localhost:4513/search?q=lamp"
<main><h1>Search</h1><p id="status">idle</p><ul></ul></main>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/search","query":{},"buildId":"LCp5upZLKpqGen2ZO61H0","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]}</script>

Headless Chrome on the same URL, with a MutationObserver recording every change to the <main> text, then two router.push calls 50 ms apart (?q=desk, then ?q=shade):

headless Chrome (CDP), client-fetch (ms since navigation; heading | status | results)
   1 ms  Search | idle |
  39 ms  Results for "lamp" | idle |
  39 ms  Results for "lamp" | loading |
 359 ms  Results for "lamp" | done | Desk lamp, Floor lamp, Lamp shade
network: GET /api/search?q=lamp 200

router.push("/search?q=desk"), then 50 ms later router.push("/search?q=shade"):
  Results for "desk" | done | Desk lamp, Floor lamp, Lamp shade      (for 1 ms)
  Results for "desk" | loading |
  Results for "shade" | loading |                                   (+50 ms)
  Results for "shade" | done | Lamp shade                           (+352 ms)
network: request ?q=desk, request ?q=shade, failed (net::ERR_ABORTED, canceled) ?q=desk, 200 ?q=shade
What’s happening
  1. The HTML has no query and no results: "query":{} in __NEXT_DATA__, heading "Search", status idle, even though the URL had ?q=lamp. That's what a crawler indexes.
  2. 39 ms after navigation, hydration had finished and the router was ready: the heading switched to Results for "lamp" and, in the same moment, the effect set loading.
  3. The API's 300 ms delay is visible: results arrived at 359 ms. Until then the user saw a loading state. With getServerSideProps they would have waited the same 300 ms for the whole page instead.
  4. On the quick double change, the q=desk request was cancelled by the effect cleanup (ERR_ABORTED, canceled), and only q=shade completed. Without the abort, whichever response arrived last would win, which can show results for an old query.
  5. For 1 ms the heading said "desk" while the list still held the "lamp" results: the render with the new q happens before the effect resets the state. If that flash matters, derive "is this result for the current query?" by storing the query with the results and comparing.
What does the server-rendered HTML contain?

A Pages Router page has no data function and renders <p>Hello {router.query.name ?? "stranger"}</p>. A user opens /hello?name=Rohit. What's in the HTML that curl sees, and what does the user see?

Show answer

curl sees Hello stranger. The user sees Hello stranger for a moment, then Hello Rohit.

What’s happening
  1. No data function means Automatic Static Optimization: the page was rendered once at build time, when there was no query string. router.query was {}, so the HTML says "Hello stranger", and every visitor gets that same file, whatever their query.
  2. The browser hydrates with the same empty query (the first client render must match the server HTML, or React reports a hydration error).
  3. After hydration the router reads the real URL, fills query with { name: "Rohit" } and sets isReady. That update re-renders the page: "Hello Rohit".
  4. To have the name in the HTML, the page needs getServerSideProps (which receives context.query). Otherwise, show a neutral placeholder until router.isReady, as the search page does, so nobody sees "stranger" flash.
AdvancedWith a data-fetching library

The docs recommend a library for client fetching rather than hand-written effects: SWR (made by the Next.js team) or TanStack Query. They give you caching per key, request deduplication, refetch on focus and reconnect, and the abort/race handling above for free. This fragment wasn't built (SWR isn't installed in the sandbox); it's the SWR 2 API as documented:

pages/dashboard.tsx (not built)
import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => {
  if (!r.ok) throw new Error(String(r.status));
  return r.json();
});

export default function Dashboard() {
  const { data, error, isLoading } = useSWR<{ name: string; unread: number }>("/api/me", fetcher);
  if (error) return <p>Could not load your account.</p>;
  if (isLoading || !data) return <p>Loading…</p>;
  return <p>{data.name}: {data.unread} unread</p>;
}
What’s happening
  1. useSWR(key, fetcher) fetches /api/me after mount and caches the result under that key, so every component using the same key shares one request and one cache entry.
  2. It returns the current data, error and isLoading; the component renders a state for each.
  3. When the window regains focus or the network reconnects, SWR revalidates the key in the background and re-renders if the data changed ("stale-while-revalidate" in the browser, the same idea as ISR on the server).
  4. The page itself is still static (○): a CDN serves the shell, and only /api/me runs per user.
AdvancedChoosing where to fetch
PatternData in the HTML?Per-request server workTypical page
getStaticProps (+ revalidate)yes, from build/regenerationnone (or background)blog, docs, product pages
getServerSidePropsyes, per requestthe whole page, every requestpersonalised or real-time pages that need SEO
Static page + client fetchno, a loading stateonly the API callsdashboards, account areas, search-as-you-type
getStaticProps + client fetch for the personal partpublic part yesonly the personal API callproduct page with "your cart" / "recently viewed"

#Migrating from Pages to App Router

Added

The App Router was designed to be adopted one route at a time: app/ and pages/ work side by side in the same project, each serving its own URLs, so a migration doesn't need a big-bang rewrite or a feature freeze. You add app/layout.tsx, move a route, ship, and repeat. API Routes don't have to move at all.

What changes is less the file layout than the mental model. In the Pages Router, a page is a client component, and data arrives through one page-level function as props. In the App Router, components are Server Components by default and fetch their own data where they need it, caching is declared per function or component ('use cache' and cacheLife with Cache Components in 16), and only the interactive leaves are Client Components. So "migrate this page" really means: move its data function into the server component that renders it, turn revalidate into a cache lifetime, and push 'use client' down to the parts that use state or effects.

The table below is the map interviewers probe. The right-hand column is the Next.js 16 model with Cache Components (the default for new apps); where the previous App Router model differs, it says so.

Pages RouterApp Router (Next.js 16, Cache Components)
pages/about.tsx, pages/blog/[slug].tsxapp/about/page.tsx, app/blog/[slug]/page.tsx (a folder per segment, page.tsx makes it public)
pages/_app.tsx + pages/_document.tsxapp/layout.tsx, the root layout, which renders <html> and <body> itself
getLayout per-page layoutsnested layout.tsx files, persisted automatically
next/headexport const metadata or generateMetadata()
getServerSidePropsan async Server Component that awaits cookies(), headers(), params, searchParams or uncached data, inside <Suspense>
getStaticPropsa Server Component whose data comes from a 'use cache' function (13 and 14: fetch was cached by default; 15 without Cache Components: opt in with cache: 'force-cache')
revalidate: 60cacheLife({ revalidate: 60 }) or a profile like cacheLife('minutes') (previous model: fetch(url, { next: { revalidate: 60 } }) or export const revalidate = 60)
getStaticPathsgenerateStaticParams() (returns [{ id: "1" }], not { paths: [{ params }] }; with Cache Components it must return at least one entry)
fallback: falseprevious model: export const dynamicParams = false; with Cache Components dynamicParams isn't available, so call notFound() for unknown ids
fallback: true / router.isFallbacka <Suspense> fallback around the code that awaits params (with Partial Prefetching): the App Shell is served for unknown ids
fallback: 'blocking'await params outside <Suspense>: the docs' mapping for apps without Partial Prefetching. With it on (the template default) an unknown id gets the App Shell first; add export const ensureStatic = "navigation" to make the first request wait (both measured below)
res.revalidate("/path") in an API RouterevalidatePath("/path") or revalidateTag(tag, "max") in a Server Action or Route Handler; updateTag(tag) in a Server Action only
pages/api/*keep them (they still work), or app/**/route.ts Route Handlers; for your own forms, Server Actions
useRouter from next/routeruseRouter, usePathname, useSearchParams, useParams from next/navigation (no query, asPath, isReady, isFallback, events)
router.eventsan effect on usePathname() + useSearchParams() in a client component
pages/404.tsx, pages/500.tsx, pages/_error.tsxnot-found.tsx, error.tsx per segment, global-error.tsx
getInitialPropsgone: fetch in Server Components
global CSS only in _appglobal CSS can be imported in any layout or page
i18n in next.confignot used by app/: a [lang] segment plus proxy.ts
Preview ModeDraft Mode (draftMode()), which both routers have

The migrated page is the product page from earlier, with ISR and fallback: 'blocking'. Before and after, side by side; the data module is identical in both apps.

pages/products/[id].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import Head from "next/head";
import { getFeaturedIds, getProduct, type Product } from "../../lib/products";

type Props = { product: Product; generatedAt: string };

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: (await getFeaturedIds()).map((id) => ({ params: { id } })),
  fallback: "blocking",
});

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const product = await getProduct(String(params?.id));
  if (!product) return { notFound: true };
  return { props: { product, generatedAt: new Date().toISOString() }, revalidate: 10 };
};

export default function ProductPage({ product, generatedAt }: Props) {
  return (
    <>
      <Head>
        <title>{product.name}</title>
      </Head>
      <h1>{product.name}</h1>
      <p>£{product.price}, generated at {generatedAt}</p>
    </>
  );
}
app/products/[id]/page.tsx
import type { Metadata } from "next";
import { cacheLife } from "next/cache";
import { notFound } from "next/navigation";
import { getFeaturedIds, getProduct } from "../../../lib/products";

export async function generateStaticParams() {
  return (await getFeaturedIds()).map((id) => ({ id }));
}

async function loadProduct(id: string) {
  "use cache";
  cacheLife({ revalidate: 10 });
  const product = await getProduct(id);
  return product && { product, generatedAt: new Date().toISOString() };
}

export async function generateMetadata({ params }: PageProps<"/products/[id]">): Promise<Metadata> {
  const { id } = await params;
  const data = await loadProduct(id);
  return { title: data?.product.name };
}

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params; // awaited outside any <Suspense> (see step 5)
  const data = await loadProduct(id);
  if (!data) notFound();
  return (
    <>
      <h1>{data.product.name}</h1>
      <p>£{data.product.price}, generated at {data.generatedAt}</p>
    </>
  );
}
What’s happening
  1. The path list: getStaticPaths returning { paths: [{ params: { id } }], fallback } becomes generateStaticParams returning [{ id }]. There is no fallback field: what happens to other ids depends on where params is awaited (step 5).
  2. The data function: getStaticProps is gone. Its body moves into loadProduct, an ordinary async function marked "use cache", so its return value is cached and keyed by its argument (id). The component calls it directly; no props are passed from a framework function.
  3. revalidate: 10 becomes cacheLife({ revalidate: 10 }) inside the cached function. The lifetime now belongs to the function that loads the data, not to the page.
  4. next/head becomes generateMetadata, which calls the same loadProduct(id). Because the function is cached, the data isn't loaded twice: in a copy of the app with a console.log in getProduct, the build logged getProduct called 1 once, and the first request for /products/2 logged getProduct called 2 once, although generateMetadata and the page both call loadProduct.
  5. notFound: true becomes notFound(), which throws to the nearest not-found boundary. params is a Promise (since Next.js 15) and is awaited at the top of the page, outside any <Suspense>. The docs map that to fallback: 'blocking', but only for apps without Partial Prefetching. This app has the template's partialPrefetching: true, and there an unknown id was served the App Shell first, closer to fallback: true (measured in "Both at runtime" below). For blocking behaviour with Partial Prefetching on, add export const ensureStatic = "navigation" to the page: the docs say an unlisted value then waits for the complete static result, and that's what I measured. Awaiting params inside a <Suspense> is the docs' fallback: true equivalent: the App Shell first, the product streamed in.
  6. What's gone: GetStaticProps types, the Props plumbing, the {product.name} duplicated into <Head>. The page is a Server Component, so none of it ships as JavaScript; a client component would only be needed for something interactive, like an "Add to basket" button.
AdvancedThe data module, unchanged

The same file in both apps: the data layer doesn't care which router calls it.

lib/products.ts
export type Product = { id: string; name: string; price: number };

const db: Product[] = [
  { id: "1", name: "Kettle", price: 35 },
  { id: "2", name: "Toaster", price: 49 },
  { id: "3", name: "Blender", price: 79 },
];

export async function getProduct(id: string): Promise<Product | null> {
  return db.find((p) => p.id === id) ?? null;
}

export async function getFeaturedIds(): Promise<string[]> {
  return ["1"];
}
lib/products.ts
export type Product = { id: string; name: string; price: number };

const db: Product[] = [
  { id: "1", name: "Kettle", price: 35 },
  { id: "2", name: "Toaster", price: 49 },
  { id: "3", name: "Blender", price: 79 },
];

export async function getProduct(id: string): Promise<Product | null> {
  return db.find((p) => p.id === id) ?? null;
}

export async function getFeaturedIds(): Promise<string[]> {
  return ["1"];
}
AdvancedBoth builds
next build
Route (app)
─ ○ /_not-found

Route (pages)       Revalidate  Expire
─   /products/[id]         10s      1y
  └ ● /products/1

○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)
next build
Route (app)
┌ ○ /_not-found
└   /products/[id]
  ├ ◐ /products/[id]
  └ ○ /products/1


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
What’s happening
  1. Before: one ● page, /products/1, generated at build time, with Revalidate 10s printed for the route.
  2. After: /products/1 is ○, fully static, because everything it needs comes from a cached function and a known param. The build didn't print a Revalidate column for it, but the runtime behaved as revalidate: 10 (next section).
  3. After also lists ◐ /products/[id]: the App Shell for ids that weren't generated, a prerendered shell into which the rest streams. The Pages Router had nothing like it; fallback: true's [id].html is the closest relative.
AdvancedBoth at runtime

Each app built, then started with next start -p 4513, then the same requests (<!-- --> removed):

curl, migrate-before (Pages Router)
18:12:05 GET /products/1 -> 200 | x-nextjs-cache: STALE  Cache-Control: s-maxage=10, stale-while-revalidate=31535990 | <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:11:51.302Z</p>
18:12:05 GET /products/2 -> 200 | x-nextjs-cache: MISS   Cache-Control: s-maxage=10, stale-while-revalidate=31535990 | <h1>Toaster</h1> <p>£49, generated at 2026-10-25T18:12:05.772Z</p>
18:12:05 GET /products/2 -> 200 | x-nextjs-cache: HIT    … | <h1>Toaster</h1> <p>£49, generated at 2026-10-25T18:12:05.772Z</p>
18:12:05 GET /products/9 -> 404 | x-nextjs-cache: HIT    Cache-Control: s-maxage=31536000 | <title>404: This page could not be found.</title>
18:12:16 GET /products/1 -> 200 | x-nextjs-cache: STALE  … | <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:12:05.711Z</p>
18:12:17 GET /products/1 -> 200 | x-nextjs-cache: HIT    … | <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:12:16.925Z</p>
curl, migrate-after (App Router)
18:12:18 GET /products/1 -> 200 | x-nextjs-cache: STALE  Cache-Control: s-maxage=10, stale-while-revalidate=31535990 | <title>Kettle</title> <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:11:54.193Z</p>
18:12:18 GET /products/2 -> 200 | Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate | <title>Toaster</title> <h1>Toaster</h1> <p>£49, generated at 2026-10-25T18:12:18.863Z</p>
18:12:18 GET /products/2 -> 200 | x-nextjs-cache: HIT    Cache-Control: s-maxage=10, stale-while-revalidate=31535990 | <h1>Toaster</h1> <p>£49, generated at 2026-10-25T18:12:18.863Z</p>
18:12:30 GET /products/1 -> 200 | x-nextjs-cache: STALE  … | <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:12:18.828Z</p>
18:12:31 GET /products/1 -> 200 | x-nextjs-cache: HIT    … | <h1>Kettle</h1> <p>£35, generated at 2026-10-25T18:12:30.016Z</p>

after a fresh build, an id that doesn't exist, three times (partialPrefetching: true, as in the template):
GET /products/8 -> 200  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate   (not-found UI, <meta name="robots" content="noindex"/>)
GET /products/8 -> 404  x-nextjs-cache: HIT   Cache-Control: s-maxage=10, stale-while-revalidate=31535990
GET /products/8 -> 404  x-nextjs-cache: HIT

the same app built with partialPrefetching: false:
GET /products/8 -> 404  x-nextjs-cache: MISS  Cache-Control: s-maxage=10, stale-while-revalidate=31535990
GET /products/2 -> 200  x-nextjs-cache: MISS  Cache-Control: s-maxage=10, stale-while-revalidate=31535990

partialPrefetching: true plus export const ensureStatic = "navigation" in the page:
GET /products/8 -> 404  x-nextjs-cache: MISS  Cache-Control: s-maxage=10, stale-while-revalidate=31535990
GET /products/2 -> 200  x-nextjs-cache: MISS  Cache-Control: s-maxage=10, stale-while-revalidate=31535990
What’s happening
  1. The ISR behaviour carried over exactly: both apps sent s-maxage=10, stale-while-revalidate=31535990, both served a STALE copy (the build copy) to the first request more than 10 seconds after the build, and the following request got a HIT with a new generatedAt. The first /products/1 was STALE in both because more than 10 seconds had passed between build and first request.
  2. An unlisted id (/products/2) rendered on its first request in both, and was a cached HIT afterwards. Before, the first response was already marked as an ISR MISS; after, the first response was sent private, no-cache and only later requests came from the cache. That's the App Shell at work: with Partial Prefetching off, or with ensureStatic = "navigation", the first response was a MISS with s-maxage=10, like the Pages version.
  3. Difference that matters: a missing id. Before, /products/9 was a 404 on the first request (blocking render, then notFound). After, the first request for a missing id answered 200 with the not-found UI and a noindex meta tag, and only the later, cached responses were 404. The App Shell for unknown ids lets the response start before notFound() is called, and once a status line is sent it can't change. Crawlers are told noindex, but monitoring that checks status codes would see a 200. (In an earlier run the second response was a MISS, in my re-run a HIT; both were 404.) Building with partialPrefetching: false, or adding ensureStatic = "navigation" to the page, gave a real 404 on the first request, as fallback: 'blocking' does.
  4. The title moved from next/head to metadata and was rendered in the HTML in both cases.

The two HTML responses for /products/1 also differ in what they carry. Measured with curl and headless Chrome against each app:

GET /products/1: before vs after
                         migrate-before (Pages)      migrate-after (App)
HTML bytes (curl)        1,685                       7,480
data in the HTML         __NEXT_DATA__ JSON props    self.__next_f (the RSC payload)
JS files loaded          7                           5
JS transferred (gzip)    112 KB                      132 KB  (447 KB uncompressed vs 357 KB)

For a page this small, the App Router version is heavier: its client runtime is bigger than the Pages Router's, and the HTML carries the RSC payload (the rendered tree) instead of a small props object. The App Router's savings come from components that no longer ship: in a real product page with a gallery, reviews, markdown rendering and their libraries, all of that would be server-only. Say this honestly in interviews: "Server Components reduce component JavaScript; the framework baseline is higher."

AdvancedMigrating a getServerSideProps page: status codes

The order page from the getServerSideProps topic, migrated. The cookie read and the database call move into an async Server Component inside <Suspense>, as Cache Components requires for request-time data, redirect and notFound become functions, and the login check moves to proxy.ts for a reason the measurements show.

app/orders/[id]/page.tsx
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { findOrder } from "../../../lib/orders";

async function OrderDetails({ params }: Pick<PageProps<"/orders/[id]">, "params">) {
  const { id } = await params;
  const user = (await cookies()).get("session")?.value;
  if (!user) redirect(`/login?next=/orders/${id}`);

  const order = await findOrder(id);
  if (!order || order.owner !== user) notFound();

  return (
    <>
      <h1>Order {order.id}: {order.item}</h1>
      <p>£{order.total} for {user}</p>
    </>
  );
}

export default function OrderPage({ params }: PageProps<"/orders/[id]">) {
  return (
    <main>
      <Suspense fallback={<p>Loading order…</p>}>
        <OrderDetails params={params} />
      </Suspense>
    </main>
  );
}
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  if (!request.cookies.has("session")) {
    const login = new URL("/login", request.url);
    login.searchParams.set("next", request.nextUrl.pathname);
    return NextResponse.redirect(login); // a real 307, sent before anything renders
  }
}

export const config = { matcher: "/orders/:path*" };
lib/orders.ts
export type Order = { id: string; item: string; total: number; owner: string };

const orders: Order[] = [
  { id: "1", item: "Running shoes", total: 89, owner: "rohit" },
  { id: "2", item: "Rain jacket", total: 120, owner: "rohit" },
  { id: "3", item: "Tent", total: 240, owner: "sam" },
];

export async function findOrder(id: string): Promise<Order | undefined> {
  await new Promise((r) => setTimeout(r, 200)); // pretend this is a database round trip
  return orders.find((o) => o.id === id);
}
app/login/page.tsx
export default function Login() {
  return <h1>Please sign in</h1>;
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /login
└   /orders/[id]
  └ ◐ /orders/[id]


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
curl, migrate-gssp (first without proxy.ts, then with it)
without proxy.ts:
GET /orders/1  (no cookie)       -> 200  ttfb 0.018 s  "Loading order…" + NEXT_REDIRECT;replace;/login?next=/orders/1;307; + <meta http-equiv="refresh" content="1;url=/login?next=/orders/1"/>
GET /orders/1  session=rohit     -> 200  ttfb 0.004 s  total 0.210 s  "Loading order…", then <h1>Order 1: Running shoes</h1>
GET /orders/3  session=rohit     -> 200  ttfb 0.002 s  total 0.209 s  not-found UI + noindex
GET /orders/99 session=rohit     -> 200  ttfb 0.002 s  total 0.206 s  not-found UI + noindex

with proxy.ts:
GET /orders/1  (no cookie)       -> 307  location: /login?next=%2Forders%2F1
GET /orders/1  session=rohit     -> 200  ttfb 0.020 s  total 0.244 s
GET /orders/3  session=rohit     -> 200  ttfb 0.006 s  total 0.214 s  not-found UI + noindex
What’s happening
  1. The route is ◐: a static shell (the layout, <main>, "Loading order…") prerendered at build time, with OrderDetails streamed per request. The getServerSideProps version had a time to first byte of 203 ms; here the first byte left after 2–4 ms and the order arrived when the 200 ms query finished (total about 210 ms). The streamed HTML puts the order in a hidden <div hidden id="S:0"> and a small script swaps it in for the fallback.
  2. Status codes are decided by the shell. By the time OrderDetails calls redirect() or notFound(), the 200 status line has already been sent. Without a cookie, the response was a 200 whose payload carried NEXT_REDIRECT;replace;/login?next=/orders/1;307; and a <meta http-equiv="refresh">, so browsers still end up on the login page, but the HTTP status isn't a redirect. A missing or foreign order was a 200 with the not-found UI and noindex, where the Pages version sent a 404.
  3. Moving the cookie check to proxy.ts restores the real 307: Proxy runs before rendering starts (the build lists it as ƒ Proxy (Middleware)). Proxy is an optimistic check; the page still verifies ownership against the data, as the authorisation topic insists.
  4. I tried the other obvious fix, awaiting cookies() and params at the top of the page with no <Suspense>. The build refused: "Error: Route "/orders/[id]": Next.js encountered uncached or runtime data during prerendering." with three suggested fixes: Suspense, "use cache", or export const instant = false to allow a blocking route. With instant = false added the build passed, but the responses were still 200 for redirect and not-found cases, because the root layout's shell was still sent first.
  5. The take-away for a migration: if a status code matters (a 404 for SEO and monitoring, a 301/307 for clients), decide it before rendering: in proxy.ts, in next.config redirects, or in a Route Handler. Inside a streamed page, notFound() and redirect() are UI outcomes.
AdvancedIncremental adoption: both routers in one app
pages/legacy.tsx
import Link from "next/link";

export default function Legacy() {
  return (
    <main>
      <h1>Legacy page (Pages Router)</h1>
      <Link href="/legacy-two">Another Pages Router page</Link> <Link href="/modern">The new App Router page</Link>
    </main>
  );
}
pages/legacy-two.tsx
import Link from "next/link";

export default function LegacyTwo() {
  return (
    <main>
      <h1>Legacy page two</h1>
      <Link href="/legacy">Back</Link>
    </main>
  );
}
app/modern/page.tsx
import Link from "next/link";

export default function Modern() {
  return (
    <main>
      <h1>Modern page (App Router)</h1>
      <Link href="/modern/settings">Settings (App Router)</Link> <Link href="/legacy">Back to the legacy page</Link>
    </main>
  );
}
app/modern/settings/page.tsx
export default function Settings() {
  return <h1>Modern settings</h1>;
}
next build
Route (app)
┌ ○ /_not-found
├ ○ /modern
└ ○ /modern/settings

Route (pages)
┌ ○ /legacy
└ ○ /legacy-two

○  (Static)  prerendered as static content
headless Chrome (CDP), mixed-routers: window.__marker set before each click
/legacy      → click "Another Pages Router page" → /legacy-two      marker kept   (no document request)
/legacy-two  → click "Back"                      → /legacy          marker kept   (no document request)
/legacy      → click "The new App Router page"   → /modern          marker GONE   200 /modern (full document)
                then prefetches: /legacy?_rsc=…, /modern/settings?_rsc=… (twice)
/modern      → click "Settings (App Router)"      → /modern/settings marker kept   (no document request)
What’s happening
  1. One build, two routers: the table has an App Router section with /modern and /modern/settings and a Pages Router section with /legacy and /legacy-two. The same <Link> component works in both.
  2. Links within the Pages Router (/legacy ↔ /legacy-two) are client-side: window.__marker survived and no document was requested. The same inside the App Router (/modern → /modern/settings).
  3. Crossing routers is a hard navigation: /legacy → /modern requested a whole new document and the marker was gone. The two routers have separate client runtimes, so any client state (a cart in context, a form) is lost at the boundary. The docs say next/link prefetching doesn't cross routers; the App Router page did still send ?_rsc= prefetch requests for /legacy, but they didn't make the click a client-side navigation.
  4. Plan the migration around that: move groups of routes that users navigate between together (all of /account/*, then all of /shop/*), so the hard navigations happen at section boundaries, not on every click.
  5. A URL can be served by only one router. Adding app/legacy/page.tsx next to pages/legacy.tsx failed the build: "Error: App Router and Pages Router both match path: /legacy. Next.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes." So moving a route is: create it in app/, delete it from pages/, in the same commit.
AdvancedA migration plan that works
  1. Upgrade first, migrate second. Get the Pages Router app onto the current Next.js and React (the codemods in npx @next/codemod cover most API changes), with next build green and tests passing. Mixing a version upgrade with a router migration makes every bug ambiguous.
  2. Add app/layout.tsx that renders <html> and <body>, with what _document and _app did: global CSS, fonts, providers (as a 'use client' provider component). Keep _app and _document: they still serve pages/, and app/layout.tsx's styles don't apply there.
  3. Move one simple, low-traffic route to prove the setup end to end: build, deploy, monitoring.
  4. For each page: the lowest-risk path from the docs is to move the page component into a 'use client' file unchanged and render it from a new Server Component page.tsx that does what the data function did. It behaves like before; then push 'use client' down and move data fetching into server components piece by piece.
  5. Replace router code: next/router → next/navigation (and next/compat/router for components shared by both routers during the transition; it returns null instead of throwing outside the Pages Router).
  6. Move sections, not pages, to keep hard navigations at section boundaries.
  7. API Routes last (or never): they keep working. Replace them with Route Handlers or Server Actions where that simplifies things.
  8. Delete _app, _document and pages/ when the last page has moved.
AdvancedCommon migration mistakes

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.