cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Data fetching in the Pages Router

Read and write getStaticProps, getStaticPaths and getServerSideProps, ISR and SWR in a Pages Router app, and know what each becomes in the App Router.

By the end, you can
  • 01
    Load a page's data the Pages Router wayPick getStaticProps, getStaticPaths or getServerSideProps for a page, and read what the build printed and what each visit got.
  • 02
    Keep static pages freshAdd revalidate for ISR, refresh a page from a signed webhook with res.revalidate, and fetch what changes by the minute with SWR.
  • 03
    Map each one to the App RouterSay what replaces each function in an App Router app with Cache Components, and why a page with getServerSideProps can't stream.

A home page made during the build

Pages Router basics built the tea shop with a pages folder, Next.js's original router, which Next.js 16.4.3 still runs. Its tea page read the tea's id from the router in the browser, so its first HTML said Loading…. A Pages Router page has no Server Components, so its component can't wait for data while it renders on the server. Instead, the page file exports a function that loads the data first, and Next.js calls it. Here is the shop with its teas in data/teas.json, a stand-in for a database. It was built with next build, run with next start, and recorded:

The tea shop's home page, made with getStaticPropsNext.js
components/data/lib/pages/teas/styles/
pages/index.tsx
import Head from "next/head";import Link from "next/link";import { getTeas, type Tea } from "@/lib/teas"; export async function getStaticProps() {  const teas = await getTeas();  return { props: { teas } };} export default function Home({ teas }: { teas: Tea[] }) {  return (    <>      <Head>        <title>Tea shop</title>      </Head>      <h1>Tea shop</h1>      <ul>        {teas.map((tea) => (          <li key={tea.id}>            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>          </li>        ))}      </ul>    </>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Running TypeScript ...  Finished TypeScript ...  Creating an optimized production build ...✓ Compiled successfully  Collecting page data ...✓ Generating static pages (6/6)  Finalizing page optimization ... Route (pages)┌ ● /├   /_app├ ○ /404└   /teas/[id]  ├ ● /teas/green  ├ ● /teas/mint  └ ● /teas/lemon ○  (Static)  prerendered as static content●  (SSG)     prerendered as static HTML (uses getStaticProps) # What .next/server/pages holds: 52 files, 85.8 kB (deeper folders added up).next/server/pages/404.html  2.8 kB.next/server/pages/500.html  2.7 kB.next/server/pages/_app.js  232 bytes.next/server/pages/_app.js.map  53 bytes.next/server/pages/_app.js.nft.json  12.8 kB.next/server/pages/_app/build-manifest.json  759 bytes.next/server/pages/_app/client-build-manifest.json  57 bytes.next/server/pages/_app/next-font-manifest.json  94 bytes.next/server/pages/_app/pages-manifest.json  30 bytes.next/server/pages/_app/react-loadable-manifest.json  2 bytes.next/server/pages/_document.js  256 bytes.next/server/pages/_document.js.map  53 bytes.next/server/pages/_document.js.nft.json  12.8 kB.next/server/pages/_document/next-font-manifest.json  94 bytes.next/server/pages/_document/pages-manifest.json  40 bytes.next/server/pages/_document/react-loadable-manifest.json  2 bytes.next/server/pages/_error.js  433 bytes.next/server/pages/_error.js.map  53 bytes.next/server/pages/_error.js.nft.json  13.2 kB.next/server/pages/_error/build-manifest.json  468 bytes.next/server/pages/_error/client-build-manifest.json  59 bytes.next/server/pages/_error/next-font-manifest.json  94 bytes.next/server/pages/_error/pages-manifest.json  34 bytes.next/server/pages/_error/react-loadable-manifest.json  2 bytes.next/server/pages/index.html  2.2 kB.next/server/pages/index.js  522 bytes.next/server/pages/index.js.map  53 bytes.next/server/pages/index.js.nft.json  13.3 kB.next/server/pages/index.json  169 bytes.next/server/pages/index.meta  178 bytes.next/server/pages/index/build-manifest.json  457 bytes.next/server/pages/index/client-build-manifest.json  53 bytes.next/server/pages/index/next-font-manifest.json  94 bytes.next/server/pages/index/pages-manifest.json  27 bytes.next/server/pages/index/react-loadable-manifest.json  2 bytes.next/server/pages/teas/[id].js  504 bytes.next/server/pages/teas/[id].js.map  53 bytes.next/server/pages/teas/[id].js.nft.json  13.7 kB.next/server/pages/teas/[id]/  673 bytes, 5 files.next/server/pages/teas/green.html  2.0 kB.next/server/pages/teas/green.json  80 bytes.next/server/pages/teas/green.meta  192 bytes.next/server/pages/teas/lemon.html  2.0 kB.next/server/pages/teas/lemon.json  80 bytes.next/server/pages/teas/lemon.meta  192 bytes.next/server/pages/teas/mint.html  2.0 kB.next/server/pages/teas/mint.json  78 bytes.next/server/pages/teas/mint.meta  191 bytes

Lines 5 to 8 of pages/index.tsx export getStaticProps. Line 6 loads the teas with getTeas from lib/teas.ts, which reads data/teas.json. Line 7 returns an object with props, and Next.js gives those props to the page's component: Home gets teas on line 10. Lines 18 to 22 list each tea with a link to its page. The other files are the shop from Pages Router basics: pages/_app.tsx wraps every page in components/layout.tsx, with the Teas link and the search box, and next.config.ts and tsconfig.json are what create-next-app writes for a Pages Router project, without Cache Components.

The Terminal tab shows what the build decided. ● / is new: the legend says (SSG) prerendered as static HTML (uses getStaticProps). SSG stands for static site generation: the page's HTML was made once, during the build, with the data getStaticProps returned. ○ /404 is a page with no data function, prerendered as static content. /_app is listed without a symbol, because it isn't a page of its own. The tea pages, /teas/[id], come with three ● lines of their own; the section on getStaticPaths explains them.

A window for next build holds getStaticProps. Arrows go from it to two files the build wrote: index.html and index.json. An arrow from index.html points to a visit to the home page. An arrow from index.json points to a click on a link to it.buildgetStaticPropsfilesindex.htmlindex.jsona visit to /a click on a link
getStaticProps ran once, during next build, and the build wrote two files for /. A visit to the address gets index.html. A click on a link to it gets index.json, and React renders the page in the browser with those props. Each tea page gets the same two files.
Real-life analogyA bakery's day

A bakery bakes most bread before it opens, so the first customer gets a loaf at once. A birthday cake with a name on it is baked only when someone orders it. When a customer asks for bread after the morning batch is a day old, the baker hands over a loaf from that batch and puts a fresh one in the oven for the next customer. The board on the wall that says what's left changes during the day, without baking anything.

In real life: Bread baked before the shop opens
In Next.js: getStaticProps: a page made during next build
In real life: A cake baked when a customer orders it
In Next.js: getServerSideProps: a page made for each request
In real life: A loaf baked again when someone asks after it's a day old
In Next.js: revalidate: a stored page made again after a request
In real life: The stock board the cashier updates while you wait
In Next.js: SWR: a number fetched in the browser
The main idea

A Pages Router page loads its data in a function it exports, and when that function runs decides how the page is made. getStaticProps runs during the build (and again for ISR), getServerSideProps runs for each request, and getStaticPaths lists which dynamic pages the build makes. Whatever the function returns as props goes to the page's component, on the server and in the browser.

getStaticProps: an HTML file and a JSON file

Open the Terminal tab again and scroll down. After the build, the recorder listed what .next/server/pages held. Most of it is the server's code and manifests. Three files belong to the home page:

  • index.html: the page's HTML, with the three teas in it. A visit to / gets this file.
  • index.json: the props getStaticProps returned, as JSON.
  • index.meta: a few details Next.js keeps about the stored page.

Each tea page got the same three, like teas/mint.html and teas/mint.json. Those names are what this build wrote, internals of Next.js 16.4.3, not something to build on. Now open the Network tab. The first response, /, has x-nextjs-cache: HIT: Next.js sent the stored page. Its Cache-Control: s-maxage=31536000 lets a shared cache, like a CDN, keep it for a year. Pick the second response, the page's data. Its address is /_next/data/<buildId>/index.json. The build id is a name next build makes up for each build, so the recording shows it as <buildId>. The body is the JSON file: {"pageProps":{"teas":[…]},"__N_SSG":true}. pageProps are the props, and __N_SSG marks them as getStaticProps data.

Props are public; the function is not

Every component of a Pages Router page also runs in the browser, so React needs the same props there to hydrate the page. The docs say any props can be seen in the initial HTML for this reason: they travel in the <script id="__NEXT_DATA__"> tag near the end of the page (the Network tab leaves out every script's text). So getStaticProps must never return a secret as a prop. The function itself is different: its code stays on the server. The recorder searched the JavaScript files / loads. They contain Tea shop, from the component, and not data/teas.json, from the function's import. Next.js removes getStaticProps and what only it imports from the browser's code.

A click loads the JSON, not the HTML

The browser only asks for a page's HTML when it loads the page as a whole. A Link to it works differently. The recorder opened / in real Chrome and clicked Mint tea. Open the Steps tab of the example above: the first step, the home page, already asked for the JSON of all three teas, because their links were on the screen. The click asked for /_next/data/<buildId>/teas/mint.json?id=mint again, and loaded no HTML page at all. Step through it below.

Playground: what a click on Mint tea loads
Who does whatHome, then a click on Mint tea
What the visitor seesafter step 1
localhost:3000/

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 6
Browser: The visitor opens the home page

The browser asks the server for /.

Each step is one thing that happened in real Chrome, in order, from the recording above. Times aren't to scale. Reset goes back to the first step.

The first requests for the teas' JSON are prefetches: the router loads what a visible link needs before the click. The second request for mint.json came when the pointer moved onto the link, so the page shows the newest copy. After the click, the step also lists a HEAD request for the same file, which asks for headers and no body. Next.js's router sends it in the background after a navigation to a getStaticProps page, so the server can make the page again if it's out of date (lines 2361 to 2376 of router.ts).

A few rules come with the function:

  • Only a page can export it, not _app, _document or a component.
  • It can't read the request: no cookies, no headers. It runs before any visitor exists.
  • Besides props, it can return notFound: true for a 404, a redirect, and revalidate, which the ISR section uses.

getStaticPaths, and teas added after the build

Each tea has its page, pages/teas/[id].tsx. The brackets make id a dynamic part of the address, so the one file answers /teas/green, /teas/mint and any other id. For the build to make these pages, it needs the list of ids. That list comes from getStaticPaths:

One page for each tea, with getStaticPathsNext.js
components/data/lib/pages/teas/styles/
pages/teas/[id].tsx
import Head from "next/head";import { useRouter } from "next/router";import { getTea, getTeas, type Tea } from "@/lib/teas"; export async function getStaticPaths() {  const teas = await getTeas();  return {    paths: teas.map((tea) => ({ params: { id: tea.id } })),    fallback: false,  };} export async function getStaticProps({ params }: { params: { id: string } }) {  const tea = await getTea(params.id);  if (!tea) return { notFound: true };  return { props: { tea } };} export default function TeaPage({ tea }: { tea: Tea }) {  const router = useRouter();  if (router.isFallback) return <p>Loading the tea…</p>;  return (    <>      <Head>        <title>{`${tea.name} | Tea shop`}</title>      </Head>      <h1>{tea.name}</h1>      <p>{`$${tea.price} a box`}</p>    </>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
x-nextjs-cache HIT
Cache-Control s-maxage=31536000
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <title data-next-head="">Mint tea | Tea shop</title>
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/1sb_tzsts-xox.js" defer=""></script>
    <script src="/_next/static/<buildId>/_buildManifest.js" defer=""></script>
    <script src="/_next/static/<buildId>/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/<buildId>/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <h1>Mint tea</h1>
        <p>$3 a box</p>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>

Lines 5 to 11 of pages/teas/[id].tsx export getStaticPaths. Line 6 reads the teas, and line 8 turns each into { params: { id: … } }. Line 9 says what happens to every other id. getStaticProps, on lines 13 to 17, now gets params: line 14 looks up the tea, and line 15 returns notFound: true for an id the shop doesn't have. The Terminal tab lists /teas/[id] with ● /teas/green, ● /teas/mint and ● /teas/lemon under it: the build ran getStaticProps three times. The Network tab shows /teas/mint as the build made it, and /teas/oolong as a 404.

The list was made during the build. So what happens when the shop adds a tea after it? Each button below is a version of the page with a different fallback on line 9, built with real Next.js. The recorder then saved oolong and jasmine through the content system (a signed POST to /api/catalog that rewrites data/teas.json, explained in the ISR section) and visited /teas/oolong twice. Before you click, guess which version makes the first visitor wait, and which one shows a loading message.

Playground: what does an unlisted tea get?
The tea shop, with this fallback in pages/teas/[id].tsxNext.js
components/data/lib/pages/api/teas/styles/
pages/teas/[id].tsx
import Head from "next/head";import { useRouter } from "next/router";import { getTea, getTeas, type Tea } from "@/lib/teas"; export async function getStaticPaths() {  const teas = await getTeas();  return {    paths: teas.map((tea) => ({ params: { id: tea.id } })),    fallback: false,  };} export async function getStaticProps({ params }: { params: { id: string } }) {  const tea = await getTea(params.id);  if (!tea) return { notFound: true };  return { props: { tea } };} export default function TeaPage({ tea }: { tea: Tea }) {  const router = useRouter();  if (router.isFallback) return <p>Loading the tea…</p>;  return (    <>      <Head>        <title>{`${tea.name} | Tea shop`}</title>      </Head>      <h1>{tea.name}</h1>      <p>{`$${tea.price} a box`}</p>    </>  );}
Recorded from Next.js 16.4.3

GET /teas/oolong404

Response headers
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <title data-next-head="">404: This page could not be found</title>
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/3kty7e_bozsbp.js" defer=""></script>
    <script src="/_next/static/2ntU1RI8aBwEdU8ZUfQrI/_buildManifest.js" defer=""></script>
    <script src="/_next/static/2ntU1RI8aBwEdU8ZUfQrI/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/2ntU1RI8aBwEdU8ZUfQrI/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center">
          <div style="line-height:48px">
            <style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style>
            <h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding-right:23px;font-size:24px;font-weight:500;vertical-align:top">404</h1>
            <div style="display:inline-block">
              <h2 style="font-size:14px;font-weight:400;line-height:28px">This page could not be found<!-- -->.</h2>
            </div>
          </div>
        </div>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>
Try it yourself
fallback: false

Line 9 says the paths the build listed are all there is. Oolong was added after the build, so /teas/oolong answered 404, though it's in data/teas.json now. Only a new build adds its page.

Each version was built with next build from Next.js 16.4.3; then oolong was added and /teas/oolong was asked for. Only line 9 of pages/teas/[id].tsx changes. Reset goes back to fallback: false.

With false, the build's list is final: oolong answered 404, though it was in data/teas.json. With true, the first request got a fallback page at once: the page's component ran with no props and router.isFallback set, so line 21 showed Loading the tea…. In the browser, the page then asks for /_next/data/<buildId>/teas/oolong.json, waits while Next.js runs getStaticProps, and shows the tea. The recorder's Chrome did that for jasmine: open the Steps tab of the fallback: true version to see its JSON request. With "blocking", the first visitor waited and got the whole page, like a page made for that request. In both, Next.js stored the new page, and the next visit was a HIT. Only the true version's build wrote one more file, teas/[id].html in its Terminal tab: the fallback page itself, ready before any visitor asks.

Three windows show the first visit to slash teas slash oolong, a tea added after the build. With fallback false: 404. With fallback true: Loading the tea, then an arrow, JSON arrives, then Oolong tea $5. With fallback blocking: a wait, then Oolong tea $5. Under the first: every visit 404. Under the others: next visit HIT.fallback: falseoolong404every visit: 404fallback: trueoolongLoading the tea…JSONOolong tea: $5next visit: HITfallback: "blocking"oolongwaits for the pageOolong tea: $5next visit: HIT
The first visit to /teas/oolong, a tea added after the build. false answers 404 until the next build. true sends the fallback page at once and fills it in from the JSON. "blocking" waits for the whole page. Both store the page for the next visit.

Each row is one setting, and the two visits to oolong:

A tea added after the build, three ways
fallbackThe first visit to /teas/oolongThe next visit
fallback: false404, the not-found page, even though oolong is in data/teas.json now.404 too, until a new build lists oolong.
fallback: trueThe fallback page at once, Loading the tea…, with MISS. In the browser, the page asks for oolong's JSON, then shows the tea.The stored page, whole, with HIT.
fallback: "blocking"A wait while getStaticProps runs, then the whole page, with MISS. No loading state.The stored page, whole, with HIT.

Two details are easy to miss. With fallback: true, the fallback page answers 200 even for a tea that doesn't exist: in the recording, /teas/earl-grey got Loading the tea… with 200, and only its JSON answered 404, with {"notFound":true}. And the docs say search engine bots never get a fallback page, and neither does a visitor who arrives by a Link: both wait for the whole page, as with "blocking".

getServerSideProps: a page for each request

The cart is different for every visitor, so no build can make it. getServerSideProps runs on the server for every request, and gets the request itself:

The cart, made for each requestNext.js
components/data/lib/pages/teas/styles/
pages/cart.tsx
import type { GetServerSidePropsContext } from "next";import { getTea } from "@/lib/teas"; export async function getServerSideProps({ req }: GetServerSidePropsContext) {  const tea = await getTea(req.cookies.cart ?? "");  const french = req.headers["accept-language"]?.startsWith("fr") ?? false;  return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };} type CartProps = { greeting: string; item: string | null }; export default function CartPage({ greeting, item }: CartProps) {  return (    <>      <h1>{greeting}, here is your cart</h1>      <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>    </>  );}
Recorded from Next.js 16.4.3

GET /cart200

Sent with the request
cookie cart=mint
accept-language fr-FR,fr;q=0.9
Response headers
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/2j059qeru_ayw.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_buildManifest.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <h1>Bonjour<!-- -->, here is your cart</h1>
        <p>In your cart: Mint tea</p>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>

Line 4 of pages/cart.tsx exports getServerSideProps, which gets a context object. req in it is Node.js's request, with an extra cookies object. Line 5 reads the cart cookie and looks up that tea. Line 6 reads the visitor's Accept-Language header, the languages their browser prefers. Line 7 returns the props, and lines 12 to 19 show them.

The Terminal tab prints ƒ /cart: (Dynamic) server-rendered on demand. The Network tab shows two requests for the same address. Without a cookie, the page said Hello, here is your cart and Your cart is empty. With the cookie cart=mint and a French Accept-Language, it said Bonjour, here is your cart and In your cart: Mint tea. Both came with Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, the default for a page with getServerSideProps: no cache may keep a copy. That's right for a cart. For a page that's the same for everyone, the function can set the header itself with res.setHeader("Cache-Control", …) to let a shared cache keep it, though the docs suggest ISR first. As with getStaticProps, the function stays on the server: the page's JavaScript holds here is your cart, from the component, and not accept-language, from line 6.

One thing about it matters a lot. Nothing is sent until getServerSideProps returns. Then the page renders, and the HTML goes out in one piece. If the function waits a second for a slow database, the visitor sees nothing from this page for that second. The data function runs before the page renders, so there's no loading state Next.js could send first. And when a visitor clicks a Link to a page with getServerSideProps, the docs say the browser asks the server for that page's JSON, and the function runs again.

Left, the Pages Router: getServerSideProps runs first, and only then the HTML goes out, with the greeting and the cart in it. Right, the App Router: the shell with Loading your cart goes out at once, and the cart streams into the same response later.Pages RoutergetServerSidePropsHTMLBonjour, …your cartIn your cart: Mint teasent in one pieceApp RouterfirstLoading your cart…laterthe cart, streamedshell first, then the cart
The same cart in each router. With getServerSideProps, nothing is sent until it returns, and then the whole page goes in one piece. In the App Router, the shell with Loading your cart… goes first, and the part that reads the cookie streams in after it.

ISR with revalidate and res.revalidate

A page made during the build stays the same until the next build. The shop changes its prices a few times a day, and rebuilding the whole shop for that is slow. Incremental Static Regeneration (ISR) makes one stored page again after the build. The Pages Router got it in Next.js 9.5. Add one property to what getStaticProps returns:

The tea pages with revalidate: 10Next.js
components/data/lib/pages/api/teas/styles/
pages/teas/[id].tsx
import Head from "next/head";import { useRouter } from "next/router";import { getTea, getTeas, type Tea } from "@/lib/teas"; export async function getStaticPaths() {  const teas = await getTeas();  return {    paths: teas.map((tea) => ({ params: { id: tea.id } })),    fallback: false,  };} export async function getStaticProps({ params }: { params: { id: string } }) {  const tea = await getTea(params.id);  if (!tea) return { notFound: true };  return { props: { tea }, revalidate: 10 };} export default function TeaPage({ tea }: { tea: Tea }) {  const router = useRouter();  if (router.isFallback) return <p>Loading the tea…</p>;  return (    <>      <Head>        <title>{`${tea.name} | Tea shop`}</title>      </Head>      <h1>{tea.name}</h1>      <p>{`$${tea.price} a box`}</p>    </>  );}
Recorded from Next.js 16.4.3

GET /teas/green200

Response headers
x-nextjs-cache STALE
Cache-Control s-maxage=10, stale-while-revalidate=31535990
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <title data-next-head="">Green tea | Tea shop</title>
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/1sb_tzsts-xox.js" defer=""></script>
    <script src="/_next/static/Cu5QrgDRZ0olLlOnYlPTq/_buildManifest.js" defer=""></script>
    <script src="/_next/static/Cu5QrgDRZ0olLlOnYlPTq/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/Cu5QrgDRZ0olLlOnYlPTq/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <h1>Green tea</h1>
        <p>$4 a box</p>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>

Line 16 of pages/teas/[id].tsx now returns revalidate: 10: a stored tea page may be made again once it's older than 10 seconds. The Terminal tab adds two columns, Revalidate and Expire, with 10s and 1y on the /teas/[id] line: 10 seconds to revalidate, and a year before a stored page expires. The other new files are the content system's side, and two helpers:

  • pages/api/catalog.ts is the content system's Save: a signed POST rewrites data/teas.json. Writing a file stands in for a database; it only works on one server.
  • pages/api/revalidate.ts is a webhook, which the next part explains.
  • pages/api/wait.ts and pages/api/stored.ts exist only for the recording. The first answers after 11 seconds, so time passes. The second lists the stored copies of a page and whether each holds a text; with wait=yes, it answers only once one does. With them, nothing in the recording depends on how fast the computer was.

Read the Network tab from the top. Each request comes after the one before has answered:

  1. A signed call to the webhook made /teas/green again at a known moment, so the 10 seconds start there.
  2. The next two visits got $4 a box with x-nextjs-cache: HIT.
  3. The editor saved $5, and /api/wait let 11 seconds pass. /api/stored then found no stored copy with $5: time alone made nothing.
  4. The first visit after that got $4 a box at once, with x-nextjs-cache: STALE, and started a new render in the background.
  5. Once /api/stored?…&wait=yes found the new page, the next visit got $5 a box, a HIT.

This is stale-while-revalidate: the visitor who arrives after the 10 seconds gets the old page, and their request starts the new one. The first request after the lifetime starts the render; nothing happens on a timer. So "the second visit is fresh" isn't a promise: a visit is fresh only once the new page has been stored, and on a busy site that's a moment after it started. The header said so too: Cache-Control: s-maxage=10, stale-while-revalidate=31535990. The new page was stored in .next/server/route-cache/PAGES/…/$/teas/green.html, beside the build's .next/server/pages/teas/green.html, which still said $4; those folders are internals of Next.js 16.4.3.

On demand, from a signed webhook

A lifetime is a guess. When the content system knows the moment a price changed, it can call a webhook, and the page is made again right then. In the Pages Router, an API route does this with res.revalidate:

A webhook that makes /teas/green againNext.js
components/data/lib/pages/api/teas/styles/
pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from "next";import { readBody } from "@/lib/body";import { isSigned } from "@/lib/signature"; export const config = { api: { bodyParser: false } }; export default async function handler(req: NextApiRequest, res: NextApiResponse) {  const body = await readBody(req);  if (req.method !== "POST" || !isSigned(body, req.headers["x-signature"])) {    return res.status(401).json({ error: "Bad signature" });  }   await res.revalidate("/teas/green");  return res.json({ revalidated: true });}
Recorded from Next.js 16.4.3

GET /teas/green200

Response headers
x-nextjs-cache HIT
Cache-Control s-maxage=31536000
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <title data-next-head="">Green tea | Tea shop</title>
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/1sb_tzsts-xox.js" defer=""></script>
    <script src="/_next/static/WAEMBhyJMW2yUqsv-fBxY/_buildManifest.js" defer=""></script>
    <script src="/_next/static/WAEMBhyJMW2yUqsv-fBxY/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/WAEMBhyJMW2yUqsv-fBxY/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <h1>Green tea</h1>
        <p>$5 a box</p>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>

An API route is a public address: anyone who finds it can call it. So the webhook checks who is asking before it does anything:

  • Line 5 of pages/api/revalidate.ts turns off Next.js's body parser, and line 8 reads the body as the exact bytes that arrived, with readBody from lib/body.ts. The signature is checked over those bytes, not over text or parsed JSON made from them.
  • Line 9 accepts only a POST whose x-signature header is the HMAC of that exact body, made with the shop's signing key. isSigned in lib/signature.ts is the check from Route Handlers, over bytes: lines 4 to 8 decode the key, 32 random bytes written as base64, and refuse to run with a key of any other length; line 12 accepts only 64 hex characters; line 15 compares the two signatures as bytes of the same length with timingSafeEqual. Line 10 of the webhook answers 401 to anything else.
  • Line 13 calls res.revalidate("/teas/green"). It makes the page at that exact path again, right away, and stores it.

The key in the example's .env is fixed for the recording only, so its signatures can be checked. A real app generates its own, with openssl rand -base64 32, and keeps it in a secret manager or the host's environment variables, never in the repository. The docs' own example compares a secret from the address with an environment variable. A signature of the body is safer: the key never travels, and a changed body fails the check. In the Network tab, the save alone changed nothing, and the visit after it still got $4. The call without a signature got 401, {"error":"Bad signature"}. The signed call answered {"revalidated":true}, and the next visit got $5 a box as a HIT: res.revalidate had made the page during the webhook's call, so the visitor didn't wait. A page without revalidate, like this example's, can still be made again this way. Pass the real path, like /teas/green, not /teas/[id].

Fetching in the browser with SWR

Some data changes by the minute and is fine to show a moment late, like how many boxes of a tea are left. The page around it can stay static, and the browser can ask for the number after the page loads. SWR is a small React library for this, from the team behind Next.js; its name comes from stale-while-revalidate. Here the tea page shows the stock with SWR:

A static tea page with the stock fetched in the browserNext.js
components/data/lib/pages/api/teas/styles/
components/stock.tsx
import useSWR from "swr"; async function fetcher(url: string) {  const response = await fetch(url);  return response.json();} export default function Stock({ id }: { id: string }) {  const { data } = useSWR<{ left: number }>(`/api/stock?id=${id}`, fetcher);  return <p>{data ? `Boxes left: ${data.left}` : "Checking stock…"}</p>;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/greenGreen tea | Tea shop
Teas
Green tea

$4 a box

Boxes left: 12

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (3), without the files it loaded

  1. GET/teas/green200a whole page
  2. GET/api/stock?id=green200application/json
  3. GET/_next/data/<buildId>/index.json200application/json

In components/stock.tsx, lines 3 to 6 are the fetcher: the function SWR calls to load a key. Line 9 calls useSWR with the key /api/stock?id=green, the address of an API route that reads data/stock.json. Until the data arrives, data is undefined, so line 10 shows Checking stock…; then it shows Boxes left: 12. The Steps tab shows the browser's request for /api/stock?id=green, and the page after it.

The Network tab shows the other half: the HTML that /teas/green sent says Checking stock…, and the build still printed ● /teas/green. The tea's name and price are made during the build; only the number is fetched in the browser. So a visitor without JavaScript, and a search engine that doesn't run it, never see the number. A Pages Router component can call useSWR without "use client", because every component there also runs in the browser. Fetching in the browser teaches SWR in the App Router, where it goes in a Client Component.

The same jobs in the App Router

The App Router has none of these functions. A page there is a Server Component that can wait for its data while it renders, and Next.js decides how each part is made from what the code reads. The jobs stay the same. Each row is one Pages Router tool, and what does its job in an App Router app with Cache Components on, as new projects have it:

From the Pages Router to the App Router
In the Pages RouterIn the App Router, with Cache Components
getStaticPropsAn async Server Component that awaits its data. Data that should be stored goes in a function with "use cache".
getStaticPaths and its pathsgenerateStaticParams, which returns [{ id: "green" }, …].
fallback: trueThe default for unlisted params: a loading shell from the Suspense fallback, then the page, stored for the next visit.
fallback: "blocking"export const ensureStatic = "navigation" (16.4): an unlisted param's first visit waits for the whole page.
fallback: falseNo direct equivalent with Cache Components: dynamicParams = false works only without them, and an unlisted but valid id still renders on request. notFound() rejects only an id whose data is missing.
revalidate: 10cacheLife({ revalidate: 10 }) in the cached function, or a profile like cacheLife("hours").
res.revalidate("/teas/green")revalidatePath("/teas/green"), or revalidateTag for data with a cacheTag, in a Route Handler.
getServerSideProps with reqawait cookies() and await headers() in a Server Component inside Suspense, so the shell streams first.
SWR in the pageSWR in a Client Component, a file that starts with "use client".

The 'use cache' directive, generateStaticParams, Incremental Static Regeneration and Request-time APIs teach each one; generateStaticParams records what an unlisted tea gets with and without Cache Components, which is why fallback: false has no one-line replacement. The biggest change is in the getServerSideProps row: a request-time part no longer holds up the whole page. Here is the cart in each router, asked for with the same cookie and header:

Playground: the same cart in each router
The cart, in this routerNext.js
components/data/lib/pages/teas/styles/
pages/cart.tsx
import type { GetServerSidePropsContext } from "next";import { getTea } from "@/lib/teas"; export async function getServerSideProps({ req }: GetServerSidePropsContext) {  const tea = await getTea(req.cookies.cart ?? "");  const french = req.headers["accept-language"]?.startsWith("fr") ?? false;  return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };} type CartProps = { greeting: string; item: string | null }; export default function CartPage({ greeting, item }: CartProps) {  return (    <>      <h1>{greeting}, here is your cart</h1>      <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>    </>  );}
Recorded from Next.js 16.4.3

GET /cart200

Sent with the request
cookie cart=mint
accept-language fr-FR,fr;q=0.9
Response headers
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
X-Powered-By Next.js
Content-Type text/html; charset=utf-8
Vary Accept-Encoding

The HTML that arrived, one element per line, with the code of its 11 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" data-next-head="">
    <meta name="viewport" content="width=device-width" data-next-head="">
    <link rel="preload" href="/_next/static/chunks/3lqpx571i07yu.css" as="style">
    <link rel="stylesheet" href="/_next/static/chunks/3lqpx571i07yu.css" data-n-g="">
    <noscript data-n-css=""></noscript>
    <script>…</script>
    <script src="/_next/static/chunks/3t_68x1ty_491.js" defer=""></script>
    <script src="/_next/static/chunks/1_2oau8-1i67e.js" defer=""></script>
    <script src="/_next/static/chunks/43cz2ihhg0zvk.js" defer=""></script>
    <script src="/_next/static/chunks/10_fcp04d3__p.js" defer=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" defer=""></script>
    <script src="/_next/static/chunks/2j059qeru_ayw.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_buildManifest.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_ssgManifest.js" defer=""></script>
    <script src="/_next/static/X4HrgPAHaZ8IGU5lmUsTm/_clientMiddlewareManifest.js" defer=""></script>
  </head>
  <body class="tea-shop">
    <div id="__next">
      <nav><a href="/">Teas</a><input aria-label="Search teas" value=""></nav>
      <main>
        <h1>Bonjour<!-- -->, here is your cart</h1>
        <p>In your cart: Mint tea</p>
      </main>
    </div>
    <script id="__NEXT_DATA__" type="application/json">…</script>
  </body>
</html>
Try it yourself
Pages Router: getServerSideProps

Nothing is sent until getServerSideProps on lines 4 to 8 returns. Then the whole page goes in one piece: in the Network tab, the cart is already in <main>.

Both versions were built and run with Next.js 16.4.3 and asked for /cart with the cookie cart=mint and a French Accept-Language header. Reset goes back to the Pages Router.

The App Router's cart reads the cookie and the header with await cookies() and await headers(), on lines 6 and 7 of app/cart/page.tsx, inside the Suspense on lines 19 to 21. Its build printed ◐ /cart, a static shell with dynamic content streamed in. In its Network tab, the HTML's <main> holds Loading your cart…, and the cart comes later in the same response. The Pages Router's HTML has the cart in <main> from the start, because nothing was sent until the cart was ready.

app/cart/page.tsx: the App Router's cartTSX
import { Suspense } from "react";
import { cookies, headers } from "next/headers";
import { getTea } from "@/lib/teas";

async function Cart() {
  const tea = await getTea((await cookies()).get("cart")?.value ?? "");
  const french = (await headers()).get("accept-language")?.startsWith("fr") ?? false;
  return (
    <>
      <h1>{french ? "Bonjour" : "Hello"}, here is your cart</h1>
      <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>
    </>
  );
}

export default function CartPage() {
  return (
    <main>
      <Suspense fallback={<p>Loading your cart…</p>}>
        <Cart />
      </Suspense>
    </main>
  );
}

Lines 5 to 14 are the part that needs the request, a Server Component called Cart. Lines 16 to 24 are the page, and only lines 19 to 21 wait for Cart. The docs' migration guide shows these moves with fetch options, from the caching model before Cache Components; with Cache Components on, "use cache" and cacheLife say the same thing. Migrating to the App Router moves the shop over one route at a time.

One function for each page of the shop

In a Pages Router app, each page picks one way to get its data, and its parts can add SWR. Ask two questions about each page: does it depend on who is asking, and how late may its data be? Sort these pages of the shop:

Which tool fits this page?
  • The About page, changed twice a year.
  • Brewing guides for each tea, written once.
  • A tea's page, whose price the shop edits a few times a day.
  • A tea's reviews, a few new ones each hour.
  • The cart, read from the visitor's cookie.
  • An order history behind a login.
  • Boxes left of a tea, changing by the minute.
  • An order's status while it's brewing.
Try it yourself
0 of 8 correct

Sort each page by the way it should get its data in a Pages Router app.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

A page picks one: the home page below exports both functions, and the build stops. Open the Terminal tab for the error.

The home page with both functions (a build that fails on purpose)Next.js
components/data/lib/pages/teas/styles/
pages/index.tsx
import Head from "next/head";import Link from "next/link";import { getTeas, type Tea } from "@/lib/teas"; export async function getStaticProps() {  const teas = await getTeas();  return { props: { teas } };} export async function getServerSideProps() {  return { props: { teas: await getTeas() } };} export default function Home({ teas }: { teas: Tea[] }) {  return (    <>      <Head>        <title>Tea shop</title>      </Head>      <h1>Tea shop</h1>      <ul>        {teas.map((tea) => (          <li key={tea.id}>            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>          </li>        ))}      </ul>    </>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Running TypeScript ...  Finished TypeScript ...  Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./pages/index.tsx:10:23Error: You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps   8 | }   9 |> 10 | export async function getServerSideProps() {     |                       ^^^^^^^^^^^^^^^^^^  11 |   return { props: { teas: await getTeas() } };  12 | }  13 | Ecmascript file had an error     at ignore-listed frames

Lines 10 to 12 of pages/index.tsx add getServerSideProps, and the build fails with You can not use getStaticProps or getStaticPaths with getServerSideProps. When most of a page is the same for everyone and one part isn't, keep the page static and fetch that part in the browser, as the tea page does with its stock.

Common mix-ups

  • "getStaticProps runs in the browser on navigation." No. A click loads the JSON the build wrote; getStaticProps runs during the build, or on the server for ISR and fallback pages.
  • "Props from getStaticProps stay on the server." No. The function's code does. Its props are sent to the browser, in the page's HTML and in its JSON file, so they must never hold a secret.
  • "revalidate: 10 makes a new page every 10 seconds." No. Nothing happens until a request comes after the 10 seconds. That request gets the old page, marked STALE, and starts the render.
  • "With fallback: false, a tea saved after the build gets its page right away." No. Only the next build adds it; until then, its address answers 404.
  • "fallback: true sends a 404 for a tea that doesn't exist." Not at first. The fallback page answers 200; only the JSON it asks for answers 404.
  • "getServerSideProps can stream a loading state." No. The page is sent once the function returns. A loading state needs the App Router's Suspense, or a part fetched in the browser.
  • "A secret in the webhook's address is enough." No. A signature of the body is safer: the key never travels, and a changed body fails the check.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upRead the shop's route table

This is what the build printed for the tea shop. Which symbol marks the home page, /?

Starter codeText
Route (pages)
┌ ● /
├   /_app
├ ○ /404
└   /teas/[id]
  ├ ● /teas/green
  ├ ● /teas/mint
  └ ● /teas/lemon

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

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind what a click loads

    A visitor on this home page clicks Mint tea. Which address does the browser ask the server for?

    Starter codeTSX
    import Head from "next/head";
    import Link from "next/link";
    import { getTeas, type Tea } from "@/lib/teas";
    
    export async function getStaticProps() {
      const teas = await getTeas();
      return { props: { teas } };
    }
    
    export default function Home({ teas }: { teas: Tea[] }) {
      return (
        <>
          <Head>
            <title>Tea shop</title>
          </Head>
          <h1>Tea shop</h1>
          <ul>
            {teas.map((tea) => (
              <li key={tea.id}>
                <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
              </li>
            ))}
          </ul>
        </>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeVisit a tea added after the build

      The editor adds oolong to data/teas.json after the build. What does /teas/oolong answer with this page?

      Starter codeTSX
      import Head from "next/head";
      import { useRouter } from "next/router";
      import { getTea, getTeas, type Tea } from "@/lib/teas";
      
      export async function getStaticPaths() {
        const teas = await getTeas();
        return {
          paths: teas.map((tea) => ({ params: { id: tea.id } })),
          fallback: false,
        };
      }
      
      export async function getStaticProps({ params }: { params: { id: string } }) {
        const tea = await getTea(params.id);
        if (!tea) return { notFound: true };
        return { props: { tea } };
      }
      
      export default function TeaPage({ tea }: { tea: Tea }) {
        const router = useRouter();
        if (router.isFallback) return <p>Loading the tea…</p>;
        return (
          <>
            <Head>
              <title>{`${tea.name} | Tea shop`}</title>
            </Head>
            <h1>{tea.name}</h1>
            <p>{`$${tea.price} a box`}</p>
          </>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeGive the cart its data

        Add the function this page needs, so it shows the tea in the visitor's cart cookie. Which object holds the cookie?

        Starter codeTSX
        import { getTea } from "@/lib/teas";
        
        // Show the tea whose id is in the visitor's "cart" cookie.
        export default function CartPage({ item }: { item: string | null }) {
          return <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe visit that starts the render

          /teas/green was made with Green tea at $4. Then the editor saved $5, and 11 seconds passed with no visitors. Which price does the next visit to /teas/green get?

          Starter codeTSX
          import Head from "next/head";
          import { useRouter } from "next/router";
          import { getTea, getTeas, type Tea } from "@/lib/teas";
          
          export async function getStaticPaths() {
            const teas = await getTeas();
            return {
              paths: teas.map((tea) => ({ params: { id: tea.id } })),
              fallback: false,
            };
          }
          
          export async function getStaticProps({ params }: { params: { id: string } }) {
            const tea = await getTea(params.id);
            if (!tea) return { notFound: true };
            return { props: { tea }, revalidate: 10 };
          }
          
          export default function TeaPage({ tea }: { tea: Tea }) {
            const router = useRouter();
            if (router.isFallback) return <p>Loading the tea…</p>;
            return (
              <>
                <Head>
                  <title>{`${tea.name} | Tea shop`}</title>
                </Head>
                <h1>{tea.name}</h1>
                <p>{`$${tea.price} a box`}</p>
              </>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMove the cart to the App Router

            Write app/cart/page.tsx for this cart, so the page doesn't wait for the cookie before it sends anything. What does the HTML show before the cart arrives?

            Starter codeTSX
            import type { GetServerSidePropsContext } from "next";
            import { getTea } from "@/lib/teas";
            
            export async function getServerSideProps({ req }: GetServerSidePropsContext) {
              const tea = await getTea(req.cookies.cart ?? "");
              const french = req.headers["accept-language"]?.startsWith("fr") ?? false;
              return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };
            }
            
            type CartProps = { greeting: string; item: string | null };
            
            export default function CartPage({ greeting, item }: CartProps) {
              return (
                <>
                  <h1>{greeting}, here is your cart</h1>
                  <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>
                </>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask when the function runs, and what the visitor's request gets.

              The Pages Router data fetching quiz · 7 questionsScore: first tries count
              1. Question 1 of 7When does getStaticProps in pages/index.tsx run, with no revalidate?

                Choose an answer to see the explanation.

              2. Question 2 of 7The visitor is on this home page and clicks Mint tea. What does the browser ask the server for?

                Read the code, then predictTSX
                import Head from "next/head";
                import Link from "next/link";
                import { getTeas, type Tea } from "@/lib/teas";
                
                export async function getStaticProps() {
                  const teas = await getTeas();
                  return { props: { teas } };
                }
                
                export default function Home({ teas }: { teas: Tea[] }) {
                  return (
                    <>
                      <Head>
                        <title>Tea shop</title>
                      </Head>
                      <h1>Tea shop</h1>
                      <ul>
                        {teas.map((tea) => (
                          <li key={tea.id}>
                            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
                          </li>
                        ))}
                      </ul>
                    </>
                  );
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7This page has fallback: true, and oolong was added after the build. What does a visitor's first request for /teas/oolong get?

                Read the code, then predictTSX
                import Head from "next/head";
                import { useRouter } from "next/router";
                import { getTea, getTeas, type Tea } from "@/lib/teas";
                
                export async function getStaticPaths() {
                  const teas = await getTeas();
                  return {
                    paths: teas.map((tea) => ({ params: { id: tea.id } })),
                    fallback: true,
                  };
                }
                
                export async function getStaticProps({ params }: { params: { id: string } }) {
                  const tea = await getTea(params.id);
                  if (!tea) return { notFound: true };
                  return { props: { tea } };
                }
                
                export default function TeaPage({ tea }: { tea: Tea }) {
                  const router = useRouter();
                  if (router.isFallback) return <p>Loading the tea…</p>;
                  return (
                    <>
                      <Head>
                        <title>{`${tea.name} | Tea shop`}</title>
                      </Head>
                      <h1>{tea.name}</h1>
                      <p>{`$${tea.price} a box`}</p>
                    </>
                  );
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 7Why can't getStaticProps read the visitor's cookie?

                Choose an answer to see the explanation.

              5. Question 5 of 7The tea pages have revalidate: 10. More than 10 seconds have passed since /teas/green was made. What does the next visit get?

                Choose an answer to see the explanation.

              6. Question 6 of 7What do lines 9 to 11 of this webhook protect against?

                Read the code, then predictTypeScript
                import type { NextApiRequest, NextApiResponse } from "next";
                import { readBody } from "@/lib/body";
                import { isSigned } from "@/lib/signature";
                
                export const config = { api: { bodyParser: false } };
                
                export default async function handler(req: NextApiRequest, res: NextApiResponse) {
                  const body = await readBody(req);
                  if (req.method !== "POST" || !isSigned(body, req.headers["x-signature"])) {
                    return res.status(401).json({ error: "Bad signature" });
                  }
                
                  await res.revalidate("/teas/green");
                  return res.json({ revalidated: true });
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7This is the cart page. What does the visitor see before its HTML arrives?

                Read the code, then predictTSX
                import type { GetServerSidePropsContext } from "next";
                import { getTea } from "@/lib/teas";
                
                export async function getServerSideProps({ req }: GetServerSidePropsContext) {
                  const tea = await getTea(req.cookies.cart ?? "");
                  const french = req.headers["accept-language"]?.startsWith("fr") ?? false;
                  return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };
                }
                
                type CartProps = { greeting: string; item: string | null };
                
                export default function CartPage({ greeting, item }: CartProps) {
                  return (
                    <>
                      <h1>{greeting}, here is your cart</h1>
                      <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>
                    </>
                  );
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • A Pages Router page gets its data from a function it exports, and its props go to the component on the server and in the browser.
              • getStaticProps runs during the build (●) and writes an HTML file and a JSON file. A visit gets the HTML; a Link click gets the JSON.
              • getStaticPaths lists the dynamic pages to build. fallback decides what other ids get: 404, a fallback page then the data, or a wait for the whole page.
              • getServerSideProps runs for each request with req and res (ƒ), and nothing is sent until it returns.
              • revalidate makes a stored page again after a request finds it stale; that request gets the old page. res.revalidate in a signed webhook makes it again right away.
              • In the App Router, Server Components with "use cache", generateStaticParams, cacheLife and request-time APIs in Suspense do these jobs.

              Remember the one-liner.
              In the Pages Router, when a page's data function runs decides how the page is made: once for the build, again for ISR, or for every request.

              Read it in Next.js's own code

              These links point at release v16.4.3. In lines 886 to 899 of server/render.tsx, Next.js calls getStaticProps with params and a revalidateReason (build, stale or on-demand), and no request. At lines 1106 to 1120, it calls getServerSideProps with req, res and query.

              Coming next: Migrating to the App Router. It runs both routers side by side, and moves the tea shop over one route at a time.

              CompleteFrontend Clear concepts. Working examples.