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

Incremental Static Regeneration

Keep static Next.js pages up to date without a new build: time-based and on-demand regeneration, ISR with Cache Components, and shells for new URLs.

By the end, you can
  • 01
    Explain when a page is made againSay which request starts a regeneration, what that visitor gets, and when the new page is sent.
  • 02
    Regenerate on demand, safelyWrite a webhook that checks a signature and calls revalidatePath or revalidateTag, and know what the next visit gets from each.
  • 03
    Use ISR with Cache ComponentsReplace export const revalidate with cacheLife, give new URLs a loading shell, and know where the pages are kept.

A price list that changes without a new build

The tea shop's price list, /teas, is the same for every visitor, so Next.js makes it during next build and sends the stored page to everyone. But prices change. Incremental Static Regeneration, or ISR, is how a stored page is made again after the build, one page at a time, without building the site again. Revalidating cached data showed a visit get a stale page; ISR follows the whole page from there: when the new one arrives, where it's kept, and what happens to pages the build never made. Here is the shop, a real Next.js 16.4.3 project, built with next build and run with next start. The result under the files is a recording of what happened.

The price list, with a lifetime of 10 secondsNext.js
app/api/prices/revalidate/stored/wait/teas/data/lib/
lib/prices.ts
import { readFile } from "node:fs/promises";import { cacheLife } from "next/cache"; export type Tea = { id: string; name: string; price: number }; export async function getPrices(): Promise<Tea[]> {  "use cache";  cacheLife({ revalidate: 10, expire: 3600 });  const file = await readFile("data/prices.json", "utf8");  return JSON.parse(file);}
Recorded from Next.js 16.4.3

GET /teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache STALE
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=10, stale-while-revalidate=3590
Content-Type text/html; charset=utf-8

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <ul>
        <li>Green tea: $4</li>
        <li>Mint tea: $3</li>
      </ul>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

lib/prices.ts, the file shown first, reads the prices. getPrices, on lines 6 to 11, starts with "use cache" on line 7, so Next.js keeps what it returns. Line 8, cacheLife({ revalidate: 10, expire: 3600 }), gives that result a lifetime: fresh for 10 seconds, and kept for an hour at most. Line 9 reads data/prices.json, where green tea costs $4. A real shop would pick hours, not seconds; 10 seconds keeps the recording short. app/teas/page.tsx waits for getPrices() on line 4 and lists each tea on line 10.

app/api/prices/route.ts is the price editor: the content system's Save button for this shop. Lines 6 to 8 turn away any request that isn't signed with the shop's secret, and line 10 writes the new prices into the file, which stands in for the content system's database. It changes the data and nothing else. app/api/revalidate is a webhook, and lib/signature.ts checks the signatures; both come up later. The last two files, app/api/wait and app/api/stored, are helpers only the recording needs.

Open the Network tab, which starts on a visit to /teas. Before it, the recorder had /teas made at a known moment, saved $5 for green tea, and let 11 seconds pass. This visit still got Green tea: $4, with the header x-nextjs-cache: STALE. Pick after the new page was stored above the page: now it says Green tea: $5, with x-nextjs-cache: HIT. Nobody ran next build again. The Terminal tab says why: ○ /teas 10s 1h, a static page with a Revalidate time of 10 seconds and an Expire time of an hour.

Six cards in two rows. Top row: a visit renders the page now, with green tea at 4 dollars, and its 10 seconds start; the editor saves 5 dollars, and the page still says 4 dollars; 11 seconds pass with no visitors, and nothing is made. Bottom row: a visit gets 4 dollars, marked STALE, which starts the render; the page renders again with 5 dollars, in the background; the next visit, after it was stored, gets 5 dollars, marked HIT.made now: $4editor saves $511 s, no visitors10 s start nowthe page still says $4nothing is madevisit: $4, STALErender again: $5next visit: $5, HITstarts the renderin the backgroundafter it was stored
The price list's recording, in order. Time passing makes the page old, but makes nothing new. The first visit after the 10 seconds still gets $4, and starts a render in the background. Only a visit after the new page was stored gets $5.
Real-life analogyA stack of printed catalogs at the counter

A tea shop hands out printed catalogs from a stack on the counter. The label says to reprint them after a week. Nobody watches the calendar: when a customer asks for a catalog after the week is up, the clerk hands over an old copy at once, and sends off for a reprint with the prices from the office's price book. Customers who come after the new stack arrives take the new catalog.

In real life: A stack of catalogs, printed before the shop opens
In Next.js: The page next build makes, stored and sent whole
In real life: "Reprint after a week" on the stack
In Next.js: cacheLife({ revalidate: 10, expire: 3600 })
In real life: A new price written in the office's price book
In Next.js: data/prices.json after the editor saves $5
In real life: The first customer after the week still takes an old copy, and the clerk sends off for a reprint
In Next.js: The first visit after 10 seconds: STALE, and a render in the background
In real life: Once the new stack is on the counter, customers take the new catalog
In Next.js: A visit after the new page was stored: HIT, $5
The main idea

ISR keeps a prerendered page and makes it again after the build, without a new build. Once its lifetime is over, the first request still gets the stored page at once and starts a new render in the background; the requests after the new page is stored get it. A webhook can also mark the page out of date as soon as the data changes.

What ISR solves

How Next.js renders pages named the four ways to make a page: SSG, ISR, SSR and CSR. Here is the problem ISR is for. The price list could be made in two simple ways, and both have a cost:

  • Made once, during the build. Every visitor gets the stored page at once, and the server does no work. But the page says $4 until someone builds and deploys the site again. A shop with 10,000 tea pages would build all of them for every price change.
  • Made for each request. Every visitor sees the current price, but the server renders the page and reads the prices for every visit, while the visitor waits for that work.

ISR sits between them. The page is stored like a page made during the build, so almost every visit gets it at once. Now and then, one render in the background replaces it. Next.js's docs list what that gives you: content updated without rebuilding the whole site, less work for the server because most requests get a stored page, the right Cache-Control headers added for you, and many content pages without long builds. Each row is one way to make the price list:

Three ways to make the price list's HTML
The page isWhen its HTML is madeWhat a visit costs the serverWhen a new price shows
Made once, during the buildOnce, by next build.Nothing: the stored page is sent.Only after the next build and deploy.
Made for each requestOn every visit.A render on the server for every visitor.Always current.
Made during the build, then regenerated (ISR)By next build, then again after its lifetime or after a webhook.Usually nothing: the stored page is sent. Now and then, one render in the background.After its lifetime, the first request starts a new render and later requests get it; after a webhook, the next request waits for it.

ISR is older than the App Router. The Pages Router got it in Next.js 9.5, with a revalidate number returned from getStaticProps, and on-demand ISR became stable in 12.2. The App Router has had it since Next.js 13, first with export const revalidate, and with Cache Components through cacheLife, as above. The idea stayed the same: a stored page, made again after the build.

Time-based regeneration, one request at a time

"Fresh for 10 seconds" sounds like Next.js makes a new page every 10 seconds. It doesn't, and the recording proves it. To prove it, it needs the two helper Route Handlers from the price list's files:

  • app/api/wait/route.ts answers after 11 seconds, on line 6. The recorder sends its requests one after another, so the request after it comes at least 11 seconds after the one before it, on any computer.
  • app/api/stored/route.ts lists the copies of a page that Next.js has stored in .next/server, and whether each holds a text, like Green tea: $5. With wait=yes, line 17 answers only once one of them does. So the request after it can only come after the new page was stored.

Neither belongs in a real shop. They make the order of events in the recording certain, instead of hoping the computer is fast. The recording also starts from a moment it knows, so how long the build took doesn't matter. Here are the requests, in the order the recorder sent them. Pick each one above the page in the example's Network tab:

  1. Start from a known state: a signed call to the webhook, /api/revalidate, marked /teas out of date. The visit made now rendered it on the spot, with x-nextjs-cache: MISS, and the visit asked for again got it as a HIT. The page's 10 seconds start at that render.
  2. Save $5: POST /api/prices answered {"saved":2}. The file now says $5. The stored page still says $4, and nothing told Next.js.
  3. Wait 11 seconds: GET /api/wait. The page is now older than its 10 seconds.
  4. Look before anyone visits: /api/stored, after the wait, found two copies of /teas: the build's .next/server/app/teas.html, and the one next start stored for the made now visit. Both say false: neither holds $5. Time passed, and nothing was made.
  5. The first visit after the 10 seconds got the old page, Green tea: $4, at once, with x-nextjs-cache: STALE. This request is what started a new render, in the background.
  6. Wait for the new page: /api/stored with wait=yes answered once the copy next start keeps held $5. The build's teas.html still says $4.
  7. The next visit got Green tea: $5, with x-nextjs-cache: HIT.
  8. Save $6, and visit at once: still $5, a HIT. The new page is fresh for 10 seconds, so a change inside that time waits for the next render.

This is called stale-while-revalidate: when the stored page is stale, older than its lifetime, Next.js still sends it, and revalidates it, makes it again, while it does. The visitor who starts the render never waits for it, and never sees its result. A visit gets the new page only once it's stored, and how long that takes depends on the render. So "the second visit is fresh" isn't a promise: a second visit that arrives while the render is still running gets STALE too. Step through the recording below. Before you start, guess: which visitor is the first to see $5?

Playground: when does /teas change?
Who does what/teas, over time
What the visitor seesafter step 1
localhost:3000/teas

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 9
Your code: The webhook marks /teas out of date

The recording starts from a known moment, whatever the build did: a signed call to /api/revalidate runs revalidatePath("/teas").

Each step is one thing that happened in the recording of the price list, in order. The screens are what each visitor got. Reset goes back to the first step.

The visitor at step 9, after the new page was stored. The one at step 6 started the work, and got $4. The x-nextjs-cache header said which case each visit was: MISS, HIT and STALE, as Revalidating cached data explained them. The docs list one more value, REVALIDATED, for a page made again by on-demand revalidation; none of these recordings sent it.

The rest of the timing comes from the docs and from Next.js's code, not from these recordings:

  • Expire. The second number, an hour here, is how long a stale page may still be sent. A request after that waits for a new page. (Cache lifetimes and tags covers stale, revalidate and expire.)
  • A render that fails. If an error is thrown while the page is made again, Next.js keeps sending the last page that worked, and tries again on a later request.
  • The data inside is a separate cache. Stale-while-revalidate here is about the stored page. The "use cache" result of getPrices lives in another cache, in memory, and Revalidating cached data recorded that 16.4.3's default one drops an entry once its revalidate time has passed (this version's code, at lines 104 to 125 of default.ts, not a promise of the API). So the background render read the file again, and got $5.
  • Shared caches. The header Cache-Control: s-maxage=10, stale-while-revalidate=3590 tells a CDN the same rule: keep the page for 10 seconds, then hand out the old copy for up to 3,590 more while it gets a new one. 10 plus 3,590 is 3,600 seconds, the expire time.

On-demand regeneration from a webhook

A lifetime is a guess. Prices can change the moment after a page is made, and then visitors see the old price until the lifetime runs out and someone visits. When something knows the exact moment the data changes, like the content system where the shop edits its prices, it can tell Next.js. A webhook is how: an address on your site that another service calls with a request when something happens. Here, it's a Route Handler. The price list now caches getPrices for hours, with the tag prices (lines 8 and 9 of lib/prices.ts), and this is the webhook:

The webhook the content system calls after an editNext.js
app/api/prices/revalidate/stored/wait/teas/data/lib/
app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";import { isSigned } from "@/lib/signature"; export async function POST(request: Request) {  const body = await request.text();  if (!isSigned(body, request.headers.get("x-signature"))) {    return Response.json({ error: "Bad signature" }, { status: 401 });  }   revalidatePath("/teas");  return Response.json({ revalidated: true });}
Recorded from Next.js 16.4.3

GET /teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache MISS
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=3600, stale-while-revalidate=82800
Content-Type text/html; charset=utf-8

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <ul>
        <li>Green tea: $5</li>
        <li>Mint tea: $3</li>
      </ul>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 4 makes it answer POST. A Route Handler is a public address: without a check, anyone who finds it could make the shop render pages again, as often as they like. So line 5 reads the body exactly as it arrived, and lines 6 to 8 turn the call away unless it's signed: isSigned in lib/signature.ts checks an HMAC of the body, made with the secret the shop and the content system share (SHOP_SECRET in .env), the same check Route Handlers explains line by line. Line 10, revalidatePath("/teas"), marks the stored /teas as out of date. Line 11 answers.

In the Network tab, the content system saves $5, and the visit after the save still gets $4: the stored page is good for an hour. The call that was not signed got 401. The signed call got {"revalidated":true}. The visit right after it, where the tab opens, got Green tea: $5, with x-nextjs-cache: MISS: there was no usable page left, so this visit rendered one and waited for it. The visit after that was a HIT.

A tag for the content system, with two arrows. One leads to a card: saves 5 dollars in the file; the page still says 4 dollars. The other leads to a card: the signed webhook. From the webhook, two arrows lead to two cards. revalidatePath of slash teas: the next visit waits and gets 5 dollars, MISS. revalidateTag of prices with max: the next visit gets 4 dollars at once, STALE.contentsystemsaves $5 in the filethe page still says $4signed webhookrevalidatePath("/teas")next visit waits: $5, MISSrevalidateTag("prices", "max")next visit: $4 at once, STALE
Saving the data changes nothing on the page. The webhook tells Next.js. With revalidatePath, the next visit waits for a new page. With revalidateTag("prices", "max"), it gets the old page at once, and the new one is made in the background.

revalidatePath isn't the only call. The page's data has the tag prices, so the webhook can name the data instead of the page. Each button is one version of line 10, and both were recorded with the same visits. Before you click, guess what the visit right after the webhook gets with revalidateTag("prices", "max").

Playground: what does the next visit get after the webhook?
The price list, with this version of app/api/revalidate/route.tsNext.js
app/api/prices/revalidate/stored/wait/teas/data/lib/
app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";import { isSigned } from "@/lib/signature"; export async function POST(request: Request) {  const body = await request.text();  if (!isSigned(body, request.headers.get("x-signature"))) {    return Response.json({ error: "Bad signature" }, { status: 401 });  }   revalidatePath("/teas");  return Response.json({ revalidated: true });}
Recorded from Next.js 16.4.3

GET /teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache MISS
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=3600, stale-while-revalidate=82800
Content-Type text/html; charset=utf-8

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <ul>
        <li>Green tea: $5</li>
        <li>Mint tea: $3</li>
      </ul>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
revalidatePath("/teas")

Line 10 marks the stored /teas as out of date. The next visit got no stored page to send: it waited while /teas was rendered, and got Green tea: $5 with x-nextjs-cache: MISS. The visit after that was a HIT.

Both versions were built and run with Next.js 16.4.3, with the same visits. The Network tab opens on the visit right after the webhook; pick the others above the page. Reset goes back to revalidatePath.

With revalidateTag("prices", "max"), the visit right after the webhook got $4, as STALE. The "max" profile makes the tagged entries stale, not gone, so the next request is served the old page and starts the new one, like a lifetime that ran out. Each row is one call, and what the recordings got:

Two webhooks, and the visits after them
Line 10The next visitAfter that
revalidatePath("/teas")MISS: the new price, rendered for this visit while it waited.Stored after that render. The next visit was a HIT.
revalidateTag("prices", "max")STALE: the old price at once.Rendered in the background. The visit after it was stored got the new price.

Revalidating cached data recorded both calls, with more versions of each. On a stored page, they differ in what the next visit gets. Which one to use depends on the page. revalidatePath reaches one address, whatever data it shows. A tag reaches every page whose cached work has it, at any address: the price list, each tea's page, the home page's offers. That lesson also covers revalidatePath with a layout, and updateTag, the one for a Server Action whose user must see their own change on the next page (Updating the UI after a mutation uses it). Calling either from a webhook doesn't render anything by itself: the docs say regeneration happens on the next request to the page.

ISR with Cache Components, and the older revalidate

The price list never said "this page uses ISR". With Cache Components on, as in new projects, a page gets its lifetime from the cached work inside it: getPrices was cached for hours, so /teas was stored with a Revalidate time of an hour. A page with nothing cached and nothing request-time in it has no Revalidate time, and stays as the build made it until a webhook marks it out of date.

Projects without Cache Components, which means Next.js 13 to 15, or 16 with the option off, set the lifetime on the route instead, with a constant exported from a page or layout:

app/teas/page.tsx, without Cache ComponentsTSX
import { readFile } from "node:fs/promises";
import type { Tea } from "@/lib/prices";

export const revalidate = 3600;

export default async function TeasPage() {
  const teas: Tea[] = JSON.parse(await readFile("data/prices.json", "utf8"));
  return (
    <main>
      <h1>Our teas</h1>
      <ul>
        {teas.map((tea) => (
          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
        ))}
      </ul>
    </main>
  );
}

Line 4, export const revalidate = 3600, is route segment config: an option a page or layout sets by exporting a constant. It says the route may be made again after 3,600 seconds. Line 7 reads the file directly, with no cache. With Cache Components on, line 4 stops the build, as Cache Components recorded. With Cache Components, the same hour goes on cached code:

app/teas/page.tsx, with Cache ComponentsTSX
import { cacheLife } from "next/cache";
import { getPrices } from "@/lib/prices";

export default async function TeasPage() {
  "use cache";
  cacheLife("hours");
  const teas = await getPrices();
  return (
    <main>
      <h1>Our teas</h1>
      <ul>
        {teas.map((tea) => (
          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
        ))}
      </ul>
    </main>
  );
}

Line 5, "use cache", caches the whole page, and line 6 gives it the hours profile. Try the three versions below. Each was built with real Next.js; before you click, guess what the build prints for /teas in each.

Playground: where does the lifetime go?
The price list, with this lifetimeNext.js
app/api/prices/teas/data/lib/
app/teas/page.tsx
import { readFile } from "node:fs/promises";import type { Tea } from "@/lib/prices"; export const revalidate = 3600; export default async function TeasPage() {  const teas: Tea[] = JSON.parse(await readFile("data/prices.json", "utf8"));  return (    <main>      <h1>Our teas</h1>      <ul>        {teas.map((tea) => (          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)      Revalidate  Expire┌ ○ /├ ○ /_not-found├ ƒ /api/prices└ ○ /teas                1h      1y ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand
Try it yourself
export const revalidate

Without Cache Components, line 4 sets the lifetime for the whole route: ○ /teas 1h 1y. Nothing else is cached; the page reads the file only when it's rendered.

Each version was built with next build from Next.js 16.4.3; the Terminal shows the route table, and the Network tab the Cache-Control header. Reset goes back to the version without Cache Components.

All three are ISR, with a Revalidate time of an hour. They differ in where the hour is written, and in the Expire time:

Three ways to give the price list a lifetime
The versionThe route tableCache-Control
export const revalidate = 3600 in the page, without Cache Components○ /teas 1h 1ys-maxage=3600, stale-while-revalidate=31532400
cacheLife("hours") in getPrices, with Cache Components○ /teas 1h 1ds-maxage=3600, stale-while-revalidate=82800
"use cache" and cacheLife("hours") on the page, with Cache Components○ /teas 1h 1ds-maxage=3600, stale-while-revalidate=82800
  • The older line sets only a revalidate time, and the build printed 1y under Expire.
  • A cacheLife profile sets both: hours is fresh for an hour and kept for a day, so 1h 1d.
  • On the data or on the page gave the same route here. Caching the data lets other pages reuse it, and caching the page also keeps its HTML. The 'use cache' directive compares the two.

Coming from the Pages Router, the docs map getStaticProps with revalidate to "use cache" with cacheLife, and getStaticPaths to generateStaticParams.

Loading shells for new URLs

ISR also makes pages the build never made. generateStaticParams showed it with the tea pages: the build makes a page for each listed tea, and a shell for the rest, which is the page's static part with a fallback in each hole. Here is that shop again, with the tea in data/teas.json:

app/teas/[id]/page.tsxTSX
import { Suspense } from "react";
import { getTea } from "@/lib/catalog";

export async function generateStaticParams() {
  return [{ id: "green" }, { id: "mint" }];
}

async function TeaDetails({ params }: Pick<PageProps<"/teas/[id]">, "params">) {
  const { id } = await params;
  const tea = await getTea(id);
  return <p>{tea ? `${tea.name}: $${tea.price}` : "We don't sell that tea."}</p>;
}

export default function TeaPage({ params }: PageProps<"/teas/[id]">) {
  return (
    <main>
      <h1>One tea</h1>
      <Suspense fallback={<p>Loading the tea…</p>}>
        <TeaDetails params={params} />
      </Suspense>
    </main>
  );
}

Lines 4 to 6 list green and mint for the build. TeaDetails waits for params on line 9 and reads the tea on line 10, inside the Suspense on lines 18 to 20. Oolong isn't listed. The recorder visited /teas/oolong, waited with /api/stored until a stored oolong page held Oolong tea: $5, and visited again. Pick each version below; the Network tab opens on oolong's first visit. Before you click, guess which version makes that first visitor wait.

Playground: what does the first visit to /teas/oolong get?
The tea pages, with this version of app/teas/[id]/page.tsxNext.js
app/api/stored/wait/teas/[id]/data/lib/
app/teas/[id]/page.tsx
import { Suspense } from "react";import { getTea } from "@/lib/catalog"; export async function generateStaticParams() {  return [{ id: "green" }, { id: "mint" }];} async function TeaDetails({ params }: Pick<PageProps<"/teas/[id]">, "params">) {  const { id } = await params;  const tea = await getTea(id);  return <p>{tea ? `${tea.name}: $${tea.price}` : "We don't sell that tea."}</p>;} export default function TeaPage({ params }: PageProps<"/teas/[id]">) {  return (    <main>      <h1>One tea</h1>      <Suspense fallback={<p>Loading the tea…</p>}>        <TeaDetails params={params} />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /teas/oolong200

The first 1.2 kB of this response are .next/server/app/teas/[id].html, a file next build wrote, byte for byte (the Terminal tab lists it).

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/html; charset=utf-8

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <!--&-->
    <main>
      <h1>One tea</h1>
      <!--$?-->
      <template id="B:0"></template>
      <p>Loading the tea…</p>
      <!--/$-->
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <!--/&-->
    <script>…</script>
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <div hidden="" id="S:0">
      <p>Oolong tea: $5</p>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
params inside Suspense

The first visit to /teas/oolong began with teas/[id].html, the shell: One tea and Loading the tea…. The tea streamed into the same response. After the page was stored, the next visit got it whole, as a HIT.

Both versions list green and mint for the build, and were built and run with Next.js 16.4.3. The Network tab opens on oolong's first visit; pick the visit after it was stored above the page. Reset goes back to the first version.

With params read inside Suspense, the first visitor got the shell at once, One tea and Loading the tea…, and the tea in the same response. Next.js then made the whole oolong page in the background and stored it, so the visit after it was stored got the whole page as a HIT. The docs call this upgrading the shell. They say the shell for unlisted params is sent from Next.js 16.3; earlier versions waited for a full render. It needs Partial Prefetching on, as new projects have it. The stored oolong page has the tea's lifetime, an hour, and is made again like the price list.

Some pages shouldn't show a fallback, even once. export const ensureStatic = "navigation" on line 4 of the second version asks for a complete static page. For a param the build didn't list, the docs say Next.js then waits for the whole page before it sends anything: the first visit got x-nextjs-cache: MISS and the whole page, and the build wrote the shell file empty, 0 bytes. ensureStatic is new in Next.js 16.4, and Instant navigations and partial prefetching covers its other levels. Each row is one version, and oolong's two visits:

The first lines of the waiting versionTSX
import { Suspense } from "react";
import { getTea } from "@/lib/catalog";

export const ensureStatic = "navigation";

export async function generateStaticParams() {
  return [{ id: "green" }, { id: "mint" }];
}
An unlisted tea, two ways
The tea pageOolong's first visitThe visit after it was stored
params read inside Suspense (the default)The shell from teas/[id].html at once: Loading the tea…, then the tea in the same response. Not kept by shared caches.The stored page, whole: HIT.
export const ensureStatic = "navigation"Nothing until the whole page was rendered: MISS. The shell file is empty.The stored page, whole: HIT.

Coming from the Pages Router, the docs map fallback: true in getStaticPaths to the first version, and fallback: "blocking" to a page that waits for params outside Suspense without Partial Prefetching. A link to an unlisted page with prefetch={true} starts the upgrade before the click, the docs add, so the visitor lands on the stored page.

Where regenerated pages are kept

The recording showed where next start put the new price list: /api/stored found it at .next/server/route-cache/APP_PAGE/…/$/teas.html, beside the build's .next/server/app/teas.html, which still said $4. Those names are internals of Next.js 16.4.3, not something to build on. What the docs promise is the shape:

  • Pages are kept in the server's cache. By default, that's each server's own memory, 50 MB unless cacheMaxMemorySize says otherwise, and its own disk.
  • One next start with a disk that lasts needs nothing more.
  • Several copies each keep their own pages. A webhook reaches the one copy that receives it, and the docs say the background render runs on the copy that received the request. To share, set cacheHandler in next.config.ts to a file of your own, and set cacheMaxMemorySize: 0 so no copy keeps its own in memory. A shared store alone isn't enough for webhooks: the self-hosting guide says the handler must also keep an index of which entries carry which tags in that store, and implement revalidateTag against it, so a call on one copy marks the entries out of date for every copy.
  • Servers that don't keep a disk, like many serverless hosts, lose the files when they stop. The cache is short-lived there unless a handler stores it elsewhere.
Left: a window for one next start holds two cards, memory up to 50 megabytes, and files in .next/server. Every visitor sees the same copy. Right: two windows, copy A and copy B, each holding its own pages. Arrows lead from both to a shaded card: a shared cacheHandler, like Redis. With a shared store and a shared index of tags, a webhook reaches every copy.next startmemory, up to 50 MBfiles in .next/serverone copy: every visitor sees itcopy Aits pagescopy Bits pagesshared cacheHandler, like Redisshared tag index: a webhook reaches both
One next start keeps regenerated pages in its memory and in files under .next/server. Several copies each keep their own, so a webhook reaches only one of them, unless a cacheHandler gives them a shared store with a shared index of tags.

Each row is one way to run the shop, and what a webhook reaches:

Where regenerated pages are kept
How the shop runsWhere pages are keptWho sees a webhook's change
One next start, with its own diskIn memory (50 MB by default) and in files under .next/server.Every visitor: there's only one copy.
Several copies of next start behind a load balancerEach copy keeps its own pages, in its own memory and on its own disk.Only the copy that received the webhook. Others keep their pages until their own lifetime ends.
Several copies with a shared cache handler (cacheHandler)In a store every copy reaches, like Redis, with an index of tags in the same store.Every copy, if the handler's revalidateTag marks entries through that shared index.

Two more facts from the docs:

  • Two handler options. cacheHandler, singular, is the server cache ISR uses, and it holds more than pages: the docs list App Router pages, GET Route Handler answers, Pages Router pages, and fetch results kept by the older caching model. "use cache" results use cacheHandlers, plural, which keep them in memory by default. Private and remote caches covers the plural one.
  • On a hosting platform, the platform decides. The docs list ISR as supported on a Node.js server and in Docker, not in a static export, and "platform-specific" for adapters, the plugins that fit Next.js to a host. Read your host's docs for where it keeps pages and how a webhook reaches all of them.

Choosing how each page stays fresh

For each page, ask two questions. Does something know the moment the data changes? Then use a webhook, and give the page a long lifetime as a safety net. If not, how old may the page be? Then make that its lifetime. And if it must be exact, or belongs to one visitor, it isn't a stored page at all: read it for each request, inside Suspense.

Time-based, on demand, or for each request?
  • A blog post the shop edits in a content system that can call a webhook
  • Prices in other currencies, from a rates service that has no webhook, fine if an hour old
  • The visitor's cart
  • The tea menu, changed in the shop's own admin page a few times a month
  • A banner with today's weather from a public API, fine if ten minutes old
  • The number of boxes left, which must always be exact
Try it yourself
0 of 6 correct

Sort each part of the tea shop by how it should stay fresh.

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

The docs suggest long lifetimes: an hour rather than a second. A short lifetime renders the page again almost every time someone visits, with little gain over rendering for each request. If you need a change to show at once, a webhook does that without the extra renders.

Common mix-ups

  • "ISR makes a new page when the lifetime runs out." No. Time passing only makes the stored page stale. After 11 seconds with no visits, nothing new was stored. The first request after the lifetime starts the render.
  • "The visit that starts the render gets the new page." No. It gets the old page at once, as STALE. The new one is made in the background.
  • "The second visit after the lifetime is always fresh." No. A visit gets the new page once it's stored. One that comes while the render is still running gets the old page too.
  • "Saving new data updates the page." No. The visit after the save still got $4. Only the lifetime or a webhook tells Next.js.
  • "revalidatePath and revalidateTag(…, "max") do the same thing." No. The visit after revalidatePath waited for the new page, a MISS. The visit after revalidateTag with "max" got the old one, STALE.
  • "export const revalidate still works with Cache Components." No. It stops the build. Use cacheLife on cached code.
  • "A webhook reaches every server." No. With the default cache, it reaches the one copy that receives it.

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 price list's lifetime

This is the route table from the price list's build. For how long after it's made is /teas fresh?

Starter codeText
Route (app)          Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/prices
├ ƒ /api/revalidate
├ ƒ /api/stored
├ ƒ /api/wait
└ ○ /teas                   10s      1h

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

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

    Exercise 2 · Warm-upThe visitor who starts the render

    The stored /teas says Green tea: $4. The editor saved $5, and the page's 10 seconds are over. A visitor opens /teas. What price do they see for green tea?

    Starter codeTSX
    import { getPrices } from "@/lib/prices";
    
    export default async function TeasPage() {
      const teas = await getPrices();
      return (
        <main>
          <h1>Our teas</h1>
          <ul>
            {teas.map((tea) => (
              <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
            ))}
          </ul>
        </main>
      );
    }
    

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

      Exercise 3 · PracticeLock the webhook

      Anyone can call this webhook. Make it turn away any request whose body isn't signed with SHOP_SECRET. What status should such a request get?

      Starter codeTypeScript
      import { revalidatePath } from "next/cache";
      import { isSigned } from "@/lib/signature";
      
      export async function POST(request: Request) {
        const body = await request.text();
      
        revalidatePath("/teas");
        return Response.json({ revalidated: true });
      }
      

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

        Exercise 4 · PracticePredict the visit after a tag

        The editor saved $5, then the content system called this webhook, signed. What does the very next visit to /teas get?

        Starter codeTypeScript
        import { revalidateTag } from "next/cache";
        import { isSigned } from "@/lib/signature";
        
        export async function POST(request: Request) {
          const body = await request.text();
          if (!isSigned(body, request.headers.get("x-signature"))) {
            return Response.json({ error: "Bad signature" }, { status: 401 });
          }
        
          revalidateTag("prices", "max");
          return Response.json({ revalidated: true });
        }
        

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

          Exercise 5 · ChallengeMove the lifetime to cacheLife

          The shop turns Cache Components on. Rewrite this page so /teas is still made again at most every hour. What does the route table print after /teas for your version?

          Starter codeTSX
          import { readFile } from "node:fs/promises";
          import type { Tea } from "@/lib/prices";
          
          export const revalidate = 3600;
          
          export default async function TeasPage() {
            const teas: Tea[] = JSON.parse(await readFile("data/prices.json", "utf8"));
            return (
              <main>
                <h1>Our teas</h1>
                <ul>
                  {teas.map((tea) => (
                    <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
                  ))}
                </ul>
              </main>
            );
          }
          

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

            Exercise 6 · ChallengeNo fallback for new teas

            The shop doesn't want any visitor to see Loading the tea…, even the first one on a tea it didn't list. Which line makes the first visit to an unlisted tea wait for the whole page?

            Starter codeTSX
            import { Suspense } from "react";
            import { getTea } from "@/lib/catalog";
            
            export async function generateStaticParams() {
              return [{ id: "green" }, { id: "mint" }];
            }
            
            async function TeaDetails({ params }: Pick<PageProps<"/teas/[id]">, "params">) {
              const { id } = await params;
              const tea = await getTea(id);
              return <p>{tea ? `${tea.name}: $${tea.price}` : "We don't sell that tea."}</p>;
            }
            
            export default function TeaPage({ params }: PageProps<"/teas/[id]">) {
              return (
                <main>
                  <h1>One tea</h1>
                  <Suspense fallback={<p>Loading the tea…</p>}>
                    <TeaDetails params={params} />
                  </Suspense>
                </main>
              );
            }
            

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

              Check your understanding

              For each question, ask which request comes first, and what was stored when it arrived.

              The Incremental Static Regeneration quiz · 7 questionsScore: first tries count
              1. Question 1 of 7The prices changed, and 11 seconds passed with nobody visiting /teas, whose lifetime is 10 seconds. What has Next.js done to the stored page?

                Choose an answer to see the explanation.

              2. Question 2 of 7What did the first visit after the 10 seconds get?

                Choose an answer to see the explanation.

              3. Question 3 of 7Right after a webhook called revalidatePath("/teas"), what did the next visit get?

                Choose an answer to see the explanation.

              4. Question 4 of 7What's wrong with this webhook if lines 6 to 8 are removed?

                Read the code, then predictTypeScript
                import { revalidatePath } from "next/cache";
                import { isSigned } from "@/lib/signature";
                
                export async function POST(request: Request) {
                  const body = await request.text();
                  if (!isSigned(body, request.headers.get("x-signature"))) {
                    return Response.json({ error: "Bad signature" }, { status: 401 });
                  }
                
                  revalidatePath("/teas");
                  return Response.json({ revalidated: true });
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7This page is in a project you're moving to Cache Components. What replaces line 4?

                Read the code, then predictTSX
                import { readFile } from "node:fs/promises";
                import type { Tea } from "@/lib/prices";
                
                export const revalidate = 3600;
                
                export default async function TeasPage() {
                  const teas: Tea[] = JSON.parse(await readFile("data/prices.json", "utf8"));
                  return (
                    <main>
                      <h1>Our teas</h1>
                      <ul>
                        {teas.map((tea) => (
                          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
                        ))}
                      </ul>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7Three copies of next start run behind a load balancer, each with the default cache. The webhook reaches one of them. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 7With Partial Prefetching on, what does the first visit to /teas/oolong, a tea generateStaticParams didn't list, get first?

                Read the code, then predictTSX
                import { Suspense } from "react";
                import { getTea } from "@/lib/catalog";
                
                export async function generateStaticParams() {
                  return [{ id: "green" }, { id: "mint" }];
                }
                
                async function TeaDetails({ params }: Pick<PageProps<"/teas/[id]">, "params">) {
                  const { id } = await params;
                  const tea = await getTea(id);
                  return <p>{tea ? `${tea.name}: $${tea.price}` : "We don't sell that tea."}</p>;
                }
                
                export default function TeaPage({ params }: PageProps<"/teas/[id]">) {
                  return (
                    <main>
                      <h1>One tea</h1>
                      <Suspense fallback={<p>Loading the tea…</p>}>
                        <TeaDetails params={params} />
                      </Suspense>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • ISR keeps a prerendered page and makes it again after the build, one page at a time, without a new build.
              • Time passing makes a page stale; a request makes it new. The first request after the lifetime gets the old page, STALE, and starts a render in the background. Visits after the new page is stored get it.
              • A webhook that checks a signature can call revalidatePath, after which the next visit waits for a new page, or revalidateTag(tag, "max"), after which it gets the old one while the new one is made.
              • With Cache Components, a page's lifetime comes from cacheLife on its cached work, not from export const revalidate, which stops the build.
              • Unlisted params get the shell at once, and the stored page after that. ensureStatic = "navigation" makes the first visit wait for the whole page instead.
              • One next start keeps pages in its memory and on its disk. Several copies need a cacheHandler with a shared store and a shared index of tags for a webhook to reach them all.

              Remember the one-liner.
              A stored page goes stale with time, but only a request, or a webhook and then a request, makes the new one.

              Read it in Next.js's own code

              These links point at release v16.4.3. The default cache is FileSystemCache: its set, at lines 609 to 630 of file-system-cache.ts, puts a page in memory first, then writes its files. Where they go is decided by getFilePath, at lines 742 to 778, and the route-cache folder name comes from getRouteCacheKey, at lines 93 to 113 of route-cache-key.ts. The course's last stage, Under the hood, reads them.

              Coming next: Caching before Cache Components. Many apps still run the older model, with fetch cache options, unstable_cache and route segment config: how it works, and how to move to Cache Components.

              CompleteFrontend Clear concepts. Working examples.