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

Revalidating cached data

Watch a cached copy go stale over time, then replace it on purpose with revalidateTag, updateTag, revalidatePath and refresh, and see what each one does.

By the end, you can
  • 01
    Follow a copy over timeTell what a visit gets before revalidate, after it, and after expire, and what x-nextjs-cache says each time.
  • 02
    Expire data from outsideCall revalidateTag from a webhook with the right second argument, and know where updateTag and refresh() may run.
  • 03
    Aim revalidatePathSay which pages a path reaches, with and without "layout", and why a dynamic path needs "page".

Old copies, new copies

To revalidate a cached copy is to replace it with a new one. Next.js does it in two ways: by time, when a copy's lifetime runs out (Cache lifetimes and tags set those lifetimes), and on demand, when your code says the data changed. Here is the first one, recorded. The tea shop's price list now has a profile with a very short revalidate, 10 seconds, so a recording can show it. This is a real Next.js 16.4.3 project, built with next build and run with next start:

The Teas page, with a price list that revalidates after 10 secondsNext.js
app/api/revalidate/wait/teas/lib/
lib/supplier.ts
import { cacheLife } from "next/cache"; const api = process.env.SUPPLIER_API_URL; export type Tea = { id: string; name: string; price: number }; export async function getPrices(): Promise<Tea[]> {  "use cache";  cacheLife("priceList");  const response = await fetch(`${api}/prices`);  return response.json();}
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>

Line 9 of lib/supplier.ts uses the priceList profile, which line 7 of next.config.ts defines as { revalidate: 10, expire: 3600 }. The build printed ○ /teas 10s 1h: the legend says (Static) prerendered as static content, so the page was prerendered and saved, and it can be made again after 10 seconds.

Two Route Handlers are only there for the recording, so its clock doesn't depend on how fast a computer is. app/api/revalidate/route.ts accepts a call signed with the shop's secret, and expires /teas with revalidatePath: the page is then made again on the next visit, at a moment the recording knows. app/api/wait/route.ts answers after 11 seconds, so the request after it comes at least 11 seconds later on any computer.

The recorder made the signed call, visited /teas twice in a row, called /api/wait, and visited once more. All three visits show the same prices. The difference is in one header, x-nextjs-cache, which Next.js adds to a prerendered page's answer. The first visit says MISS: the page had just been expired, so it waited while a new one was made. The second says HIT: that new page, still fresh. The Network tab is open on the last one, 11 seconds later: STALE. Each row is one value it had in these recordings:

What x-nextjs-cache said
ValueWhat it meansWhere it was recorded
HITThe saved page, still fresh, sent as it is.The visit right after the MISS.
STALEThe saved page, past its revalidate: sent at once, and a new one is started.The visit 11 seconds after that.
MISSNo usable saved page: this request waited while a new one was made.The first visit after the signed call expired /teas, and the visit after revalidateTag("price-list", { expire: 0 }).

STALE is stale-while-revalidate: the copy is out of date, but the visit still gets it at once, and that visit starts making a new one in the background. The supplier's stand-in API kept a list of the requests it got: one during the build, one for the MISS, and one more, sent by that background work. The HIT asked for nothing. Nothing happened at the 10-second mark itself: a page is only made again when a request finds it stale. A later visit gets the new page, once it's ready.

A timeline of the prerendered Teas page, which revalidates after 10 seconds. A signed call expires it. The first visit waits while a new page is made: MISS. The next visit, right after, gets that page fresh: HIT. At 10 seconds nothing happens. The visit 11 seconds later gets the same page at once, marked STALE, and starts making a new one. A later visit gets the new page once it's ready.visit: MISSwaits, a new pagevisit: HITthat page, freshvisit: STALEthe same page again,and a new one startsa later visitthe new page,once it's ready0 s10 s11 slater
After the signed call, the first visit waits for a new page (MISS), and the next gets it fresh (HIT). Nothing happens at 10 seconds by itself. The first visit after it gets the old page at once (STALE) and starts a new one; a later visit gets the new page once it's ready.
Real-life analogyThe price board at the tea shop's door

The clerk chalks the price board before the shop opens. After lunch the prices may be out of date, but the clerk doesn't watch the clock: when the first customer of the afternoon walks in, they read the morning board while the clerk starts a new one. When the supplier phones, the clerk either wipes the board at once, so the next customer waits for new prices, or notes it and updates the board after the next customer.

In real life: The board is chalked in the morning, before the shop opens
In Next.js: The page prerendered by next build
In real life: After lunch, the first customer still reads the morning board, and the clerk starts chalking a new one
In Next.js: After revalidate, the first visit gets the old page (STALE) and starts a new one
In real life: The supplier phones: "prices changed, don't show the old ones", and the board is wiped
In Next.js: revalidateTag("price-list", { expire: 0 }): the next visit waits
In real life: The supplier says "prices changed, no hurry"
In Next.js: revalidateTag("price-list", "max"): the next visit still gets the old page
The main idea

A cached copy is replaced either when a request finds it past its lifetime, or when your code expires it. On a prerendered page, past revalidate, or after revalidateTag with a profile like "max", the next visit still gets the old copy and starts a new one; the cache handler decides whether other cached results keep a stale copy. Past expire, or after an expiry of 0, the next visit waits for the new one.

Time-based revalidation

The page above shows what a lifetime does to a prerendered page. A cached result can also live in a hole: a part of the page made for each request, like a short-lived price list inside Suspense. Its copy isn't kept in a page file; Next.js keeps it in a cache handler, the part that stores "use cache" results while the server runs. Here the price list lives for 10 seconds, and expires after 60, so it's a hole:

A short-lived price list inside Suspense, visited before and after 11 secondsNext.js
app/api/wait/teas/lib/
lib/supplier.ts
import { cacheLife } from "next/cache"; const api = process.env.SUPPLIER_API_URL; export type Tea = { id: string; name: string; price: number }; export async function getPrices(): Promise<Tea[]> {  "use cache";  cacheLife({ revalidate: 10, expire: 60 });  const response = await fetch(`${api}/prices`);  return response.json();}
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-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>Our teas</h1>
      <!--$?-->
      <template id="B:0"></template>
      <p>Loading prices…</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">
      <ul>
        <li>Green tea<!-- -->: $<!-- -->4</li>
        <li>Mint tea<!-- -->: $<!-- -->3</li>
      </ul>
    </div>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 9 is cacheLife({ revalidate: 10, expire: 60 }), and the build printed ◐ /teas. The recorder visited twice, waited 11 seconds, and visited again. All three visits show the same prices, and the stand-in supplier tells what happened behind them: it was asked once during the build, and twice during the visits. The first visit filled the cache. The second visit used that copy. The third came after revalidate, and asked the supplier again.

That third visit didn't get the old copy first. In Next.js 16.4.3, the default cache handler keeps entries in the server's memory, and when it runs in production it drops an entry once its revalidate has passed. A request then finds nothing, and waits while getPrices runs again. That's this version's default handler, not a promise of the API: cacheLife only says when a copy may be refreshed in the background and when a request must wait, and other handlers, like the remote ones in Private and remote caches, may keep a stale entry and refresh it in the background. Each row compares the two places a copy can live, as recorded here:

One price list, as time passes
When a request comesA prerendered pageA result in a hole, default handler
Before revalidateSent as it is: x-nextjs-cache: HIT.Used as it is: the supplier isn't asked.
After revalidate, before expireSent at once, and a new page is started: STALE.In Next.js 16.4.3's default in-memory handler, dropped: the request waits while getPrices runs again.
After expireCan't be sent: the request waits for a new page.Dropped too.

Expire works the same way on both: once it has passed with no request in between, nothing old may be sent, and the request waits. A real price list would have an expire of hours, so a recording would have to wait hours; the source shows the check, linked at the end. Time-based revalidation fits data that changes on its own, with nobody to tell you: a weather box, a rate from another company's API. When something can tell you, revalidate on demand.

revalidateTag from a webhook

The tea supplier can tell the shop when prices change: it calls a webhook, an address on the shop's server, with the new prices. In these recordings, the shop stores them in data/prices.json, and getPrices reads that file, cached for hours and tagged price-list. The file stands in for a database so the recording needs nothing else; a real shop writes to shared, lasting storage, because many hosts run several copies of the app on file systems that are read-only or wiped on each deploy. Here is the webhook, a Route Handler (Route Handlers built one), in its first version:

app/api/supplier/route.ts: the supplier's webhookTypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { revalidateTag } from "next/cache";

function isSigned(body: string, signature: string | null) {
  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
  const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();
  const received = Buffer.from(signature, "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

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 });
  }
  const prices = JSON.parse(body);
  await writeFile("data/prices.json", JSON.stringify(prices));

  revalidateTag("price-list", "max");
  return Response.json({ received: true });
}

Anyone can send a request to this address, so a webhook that changes data must check that it really comes from the supplier. Lines 5 to 10 do it the way Route Handlers showed: isSigned computes an HMAC, a code made from the exact body and the secret the shop and the supplier share, checks that the x-signature header is 64 hex characters, and compares the two with timingSafeEqual, which takes the same time whatever the first difference. Line 12 answers POST requests, and line 14 turns away any request without a valid signature with 401. Lines 17 and 18 read the new prices and save them. Line 20 tells Next.js the price list changed. Its second argument is a cacheLife profile, and Next.js uses only that profile's expire: the tag goes stale at once, and old copies may still be sent until expire has passed. The recorder called the webhook three times: with no signature and with a header that isn't one (both 401), then signed. Try each version of line 20, and before you click, guess what the visit right after the webhook shows.

Playground: one webhook, five ways to say the prices changed
The supplier's webhook, with this call on line 20Next.js
app/api/supplier/teas/data/lib/
app/api/supplier/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";import { writeFile } from "node:fs/promises";import { revalidateTag } from "next/cache"; function isSigned(body: string, signature: string | null) {  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;  const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();  const received = Buffer.from(signature, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} 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 });  }  const prices = JSON.parse(body);  await writeFile("data/prices.json", JSON.stringify(prices));   revalidateTag("price-list", "max");  return Response.json({ received: 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 STALE
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<!-- -->: $<!-- -->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>
Try it yourself
"max"

"max": the visit right after still shows Mint tea: $3, with x-nextjs-cache: STALE. It got the old page at once and started a new one.

Every version was built with next build from Next.js 16.4.3. The recorder visited /teas, called the webhook with no signature, with a header that isn't one, and then signed, and visited /teas again. The Network tab opens on that last visit; the Terminal tab shows what next start printed. Reset goes back to the first version.

Each row is one version of line 20: what it does to the tag, and what the recorded visit right after got.

Five versions of line 20
Line 20What happens to the price listThe visit right after
revalidateTag("price-list", "max")Stale now; may still be sent for a year.The old prices, x-nextjs-cache: STALE.
revalidateTag("price-list", "hours")Stale now; may still be sent for a day, the hours expire.The old prices, STALE.
revalidateTag("price-list", { expire: 0 })Expired now.The new prices, MISS: the visit waited.
revalidateTag("price-list")A TypeScript error in a .ts file. In a .js file: a warning, then expired now.The new prices, MISS.
updateTag("price-list")Throws in a Route Handler: the request answers 500.The old prices, HIT: nothing expired.
Two rows. Top: the webhook calls revalidateTag with max; the next visit gets the old prices at once, marked STALE, and a new page is started. Bottom: the webhook calls revalidateTag with expire 0; the next visit waits and gets the new prices, marked MISS.webhook"max"Mint tea: $3next visit: STALE, at onceold, and a newpage is startedwebhook{ expire: 0 }Mint tea: $4next visit: MISS, it waitednew
The second argument decides who waits. With "max", no visit has to within its year-long window: the next visit gets the old prices and starts new ones. With { expire: 0 }, the old copy can't be sent, so the next visit waits for the new prices.

Three things to notice:

  • "max" is the usual choice, and the one the docs recommend: its expire is a year, so for a year after the call, a request finds an old copy it may send and needn't wait; the visit after the new page is ready shows the new prices. Past expire, the next request would wait. "hours" behaved the same here, because only its expire, a day, counts.
  • { expire: 0 } is for data that must never be shown old: the next visit waits, and gets Mint tea: $4.
  • The one-argument form is from before Next.js 16, whose release post says the second argument is now required. In a TypeScript file, it doesn't build: the recorded build stopped at line 20 with Expected 2 arguments, but got 1. In a JavaScript file, it still runs, prints "revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag", and expires the tag at once. Add "max" when you update old code.

Where each call may run

The last version of the webhook called updateTag. The signed call answered 500, and next start printed Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. The file was already saved, but nothing expired, so /teas kept the old prices.

updateTag expires a tag at once, like { expire: 0 }, and inside a Server Action it also sends the page back, rendered again, in the action's own answer: the person who made the change sees it right away. Updating the UI after a mutation recorded that flow, with revalidatePath, refresh() and redirect(). A webhook has nobody waiting for a page, so it uses revalidateTag.

None of these calls may run while a page renders. This page tries:

A page that revalidates while it renders (an error on purpose)Next.js
app/teas/data/lib/
app/teas/page.tsx
import { revalidateTag } from "next/cache";import { getPrices } from "@/lib/shop"; export default async function TeasPage() {  revalidateTag("price-list", "max");  const teas = await getPrices();  return (    <main>      <h1>{teas.length} teas</h1>    </main>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...Error occurred prerendering page "/teas". Read more: https://nextjs.org/docs/messages/prerender-errorError: Route "/teas": `revalidateTag("price-list")` can't be called during render. Call it from a Server Action or Route Handler instead.Learn more: https://nextjs.org/docs/messages/revalidate-in-use-cache    at e (app/teas/page.tsx:5:3)  3 |  4 | export default async function TeasPage() {> 5 |   revalidateTag("price-list", "max");    |   ^  6 |   const teas = await getPrices();  7 |   return (  8 |     <main> {  digest: '2424803203'}Export encountered an error on /teas/page: /teas, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null

Line 5 runs while TeasPage renders, and the build stops there: revalidateTag("price-list") can't be called during render. Call it from a Server Action or Route Handler instead. Rendering reads data; changing what's cached belongs to the code that changes the data. Inside a "use cache" function, the same calls stop with can't be called inside a cached function. Each row is one call, and where it may run:

Where each call may run
CallIn a Server ActionIn a Route HandlerWhile a page renders
revalidateTag(tag, profile)YesYesNo: the build stops
updateTag(tag)YesNo: it throws, 500No
revalidatePath(path, type?)YesYesNo
refresh()YesNo: it throwsNo

How far revalidatePath reaches

revalidatePath names pages by their address instead of a tag, and expires everything cached for them at once, so the next visit waits for new data. The shop now has three pages that use the prices: the home page with the cheapest tea, /teas, and /teas/offers, which sells two boxes of each tea. app/teas/layout.tsx is a layout around the two teas pages (Layouts and pages covered layouts). Before you click, guess which pages the first version reaches.

Playground: how far does revalidatePath reach?
The supplier's webhook, with this revalidatePath on line 20Next.js
app/api/supplier/teas/offers/data/lib/
app/api/supplier/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";import { writeFile } from "node:fs/promises";import { revalidatePath } from "next/cache"; function isSigned(body: string, signature: string | null) {  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;  const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();  const received = Buffer.from(signature, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} 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 });  }  const prices = JSON.parse(body);  await writeFile("data/prices.json", JSON.stringify(prices));   revalidatePath("/teas");  return Response.json({ received: true });}
Recorded from Next.js 16.4.3

GET /teas/offers200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
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>
    <!--&-->
    <section>
      <p>Prices from our supplier.</p>
      <!--&-->
      <!--&-->
      <main>
        <h1>Offers</h1>
        <ul>
          <li>Two boxes of <!-- -->Green tea<!-- -->: $<!-- -->8</li>
          <li>Two boxes of <!-- -->Mint tea<!-- -->: $<!-- -->6</li>
        </ul>
      </main>
      <!--$-->
      <!--/$-->
      <!--/&-->
      <!--/&-->
    </section>
    <!--/&-->
    <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
"/teas"

"/teas": only /teas shows the new prices. / still says Mint tea at $3, and /teas/offers still sells two boxes of Mint tea for $6.

Every version was built with next build from Next.js 16.4.3. The recorder called the webhook as the supplier, then visited /, /teas and /teas/offers. The Network tab opens on /teas/offers; pick the other pages above it. Reset goes back to the first version.

Each row is one call, and what each page showed on the visit after the webhook:

What each page showed after the webhook
Line 20//teas/teas/offers
revalidatePath("/teas")Mint tea at $3 (old)Mint tea: $4 (new)Two boxes of Mint tea: $6 (old)
revalidatePath("/teas", "layout")Mint tea at $3 (old)Mint tea: $4 (new)Two boxes of Mint tea: $8 (new)
revalidatePath("/", "layout")Mint tea at $4 (new)Mint tea: $4 (new)Two boxes of Mint tea: $8 (new)
An editor window for the app folder: the root layout holds the home page and the teas layout; the teas layout holds the Teas page and the Offers page. revalidatePath slash teas reaches the Teas page only. revalidatePath slash teas with layout reaches both pages under the teas layout. revalidatePath slash with layout reaches every page.applayout.tsx: /page.tsx: Hometeas/layout.tsxteas/page.tsx: Our teasteas/offers/page.tsx"/teas"this page only"/teas", "layout"both teas pages"/", "layout"every page
A path alone reaches one page: "/teas" is app/teas/page.tsx. With "layout", it reaches every page under that layout: "/teas" both teas pages, "/" the whole app.

Its second argument, the type, decides the reach. Without one, or with "page", the path names one page. With "layout", it names a layout, and reaches every page under it. So revalidatePath("/", "layout") expires everything cached for the whole app.

A dynamic route, like app/teas/[id]/page.tsx with a page for each tea (Dynamic routes), needs the type. Its folder name, /teas/[id], isn't an address anyone visits:

Playground: revalidating every tea's page
The supplier's webhook, with this revalidatePath on line 20Next.js
app/api/supplier/teas/[id]/data/lib/
app/api/supplier/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";import { writeFile } from "node:fs/promises";import { revalidatePath } from "next/cache"; function isSigned(body: string, signature: string | null) {  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;  const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();  const received = Buffer.from(signature, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} 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 });  }  const prices = JSON.parse(body);  await writeFile("data/prices.json", JSON.stringify(prices));   revalidatePath("/teas/[id]");  return Response.json({ received: true });}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
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 16 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>Mint tea</h1>
      <p>Price: $<!-- -->3</p>
      <!--/$-->
    </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>
    <script>…</script>
  </body>
</html>
Try it yourself
"/teas/[id]"

"/teas/[id]" alone: next start printed a warning that it has no effect, and both teas kept their old prices.

Both versions were built with next build from Next.js 16.4.3. The recorder called the webhook as the supplier, then visited /teas/green and /teas/mint. The Terminal tab shows what next start printed. Reset goes back to the first version.

Without a type, next start printed Warning: a dynamic page path "/teas/[id]" was passed to "revalidatePath", but the "type" parameter is missing. This has no effect by default, and both teas kept their old prices. With "page", both show the new ones. A real address, like "/teas/mint", names one tea's page. The docs prefer tags when you have them: a tag says exactly which data changed, wherever it's used; a path says which pages to make again.

refresh() for data that isn't cached

Not every change is to cached data. A count of today's orders, read for each request with nothing cached in between, is always current on the server; only the page already on the visitor's screen is old. refresh(), from next/cache, is for that. It expires nothing. Called in a Server Action, it asks Next.js to render the current page again and send it back in the action's answer:

app/orders/actions.ts: a Server Action that marks an order packedTypeScript
"use server";

import { refresh } from "next/cache";
import { markPacked } from "@/lib/orders";

export async function packOrder(id: string) {
  await markPacked(id);
  refresh();
}

Line 1 makes the file's functions Server Actions (Server Actions). Line 7 saves the change, and line 8 calls refresh(). The parts of the page that read data for each request come back new; cached parts come back as they were. Updating the UI after a mutation recorded exactly that: the saved count went up, and the cached list didn't change. Like updateTag, refresh() only works in a Server Action: anywhere else it throws refresh can only be called from within a Server Action.

Choosing how a copy is replaced

Ask two questions about each piece of cached data. Does anything tell you when it changes? And may a visitor see the old version once more? Sort these parts of the tea shop:

How should this copy be replaced?
  • The weather box on the home page, from a weather API with no webhook.
  • The CMS calls a webhook when an editor fixes a typo in a tea story.
  • The supplier's webhook says prices went up, and checkout must not show old prices.
  • Asha, on the staff page, adds a tea with a form and expects to see it.
  • Shipping rates from a carrier's API that change once a day.
  • A warehouse system calls a webhook after each delivery; a few seconds of old stock is fine.
  • A tea is recalled, and its page must disappear from the shop at once.
  • A customer posts a review with a form and wants to see it on the tea's page.
Try it yourself
0 of 8 correct

Sort each change by the call that fits it best.

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

Most real apps mix them: a lifetime as a safety net for every cached result, a tag for each kind of data, "max" from webhooks, and updateTag in the actions where someone waits for their own change.

Common mix-ups

  • "After revalidateTag with "max", the next visit shows the new data." No. The next visit gets the old copy, marked STALE, and starts a new one. A later visit gets the new data, once it's ready.
  • "A page with a revalidate of 10 seconds is made again every 10 seconds." No. Nothing happens while nobody visits. The first request after revalidate starts the new page.
  • "Every cached result is served stale while it refreshes." Not always. In Next.js 16.4.3, the default in-memory handler drops a result once its revalidate has passed, in production, and the request waits.
  • "updateTag works in a webhook." No. It throws in a Route Handler, and the request answers 500. Use revalidateTag there.
  • "revalidatePath("/teas") refreshes everything under /teas." Only with "layout". Without it, /teas/offers kept its old prices.
  • "refresh() clears the cache." No. It renders the page again and expires nothing.
  • "A webhook only needs a secret address." No. Anyone who learns the address can call it. Check who is asking on every request, as lines 5 to 10 and 14 do.

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 cache header

With this next.config.ts, a visit makes a new copy of /teas, and the next visit comes 11 seconds later. What does x-nextjs-cache say?

Starter codeTypeScript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  cacheLife: {
    priceList: { revalidate: 10, expire: 3600 },
  },
};

export default nextConfig;

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

    Exercise 2 · Warm-upCount the supplier's requests

    This price list sits inside Suspense. /teas is visited twice, then once more 11 seconds later. How many times is the supplier asked during the visits?

    Starter codeTypeScript
    import { cacheLife } from "next/cache";
    
    const api = process.env.SUPPLIER_API_URL;
    
    export type Tea = { id: string; name: string; price: number };
    
    export async function getPrices(): Promise<Tea[]> {
      "use cache";
      cacheLife({ revalidate: 10, expire: 60 });
      const response = await fetch(`${api}/prices`);
      return response.json();
    }
    

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

      Exercise 3 · PracticeRight after a max

      The supplier sends Mint tea's new price, $4, to this webhook. What does /teas show for Mint tea on the visit right after?

      Starter codeTypeScript
      import { createHmac, timingSafeEqual } from "node:crypto";
      import { writeFile } from "node:fs/promises";
      import { revalidateTag } from "next/cache";
      
      function isSigned(body: string, signature: string | null) {
        if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
        const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();
        const received = Buffer.from(signature, "hex");
        return received.length === expected.length && timingSafeEqual(received, expected);
      }
      
      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 });
        }
        const prices = JSON.parse(body);
        await writeFile("data/prices.json", JSON.stringify(prices));
      
        revalidateTag("price-list", "max");
        return Response.json({ received: true });
      }
      

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

        Exercise 4 · PracticeReach every tea's page

        After this webhook, /teas/green and /teas/mint still show the old prices. What should line 20 be?

        Starter codeTypeScript
        import { createHmac, timingSafeEqual } from "node:crypto";
        import { writeFile } from "node:fs/promises";
        import { revalidatePath } from "next/cache";
        
        function isSigned(body: string, signature: string | null) {
          if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
          const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();
          const received = Buffer.from(signature, "hex");
          return received.length === expected.length && timingSafeEqual(received, expected);
        }
        
        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 });
          }
          const prices = JSON.parse(body);
          await writeFile("data/prices.json", JSON.stringify(prices));
        
          revalidatePath("/teas/[id]");
          return Response.json({ received: true });
        }
        

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

          Exercise 5 · ChallengeWhich pages does the layout reach?

          The shop has /, /teas and /teas/offers, and the last two share app/teas/layout.tsx. Which pages show the new prices after this webhook?

          Starter codeTypeScript
          import { createHmac, timingSafeEqual } from "node:crypto";
          import { writeFile } from "node:fs/promises";
          import { revalidatePath } from "next/cache";
          
          function isSigned(body: string, signature: string | null) {
            if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
            const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();
            const received = Buffer.from(signature, "hex");
            return received.length === expected.length && timingSafeEqual(received, expected);
          }
          
          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 });
            }
            const prices = JSON.parse(body);
            await writeFile("data/prices.json", JSON.stringify(prices));
          
            revalidatePath("/teas", "layout");
            return Response.json({ received: true });
          }
          

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

            Exercise 6 · ChallengeAn old webhook in JavaScript

            An older project's webhook is JavaScript, route.js, and calls revalidateTag with one argument. The supplier sends $4 for Mint tea. What does the visit right after show for Mint tea?

            Starter codeJavaScript
            import { createHmac, timingSafeEqual } from "node:crypto";
            import { writeFile } from "node:fs/promises";
            import { revalidateTag } from "next/cache";
            
            function isSigned(body, signature) {
              if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
              const expected = createHmac("sha256", process.env.SUPPLIER_SECRET).update(body).digest();
              const received = Buffer.from(signature, "hex");
              return received.length === expected.length && timingSafeEqual(received, expected);
            }
            
            export async function POST(request) {
              const body = await request.text();
              if (!isSigned(body, request.headers.get("x-signature"))) {
                return Response.json({ error: "Bad signature" }, { status: 401 });
              }
              const prices = JSON.parse(body);
              await writeFile("data/prices.json", JSON.stringify(prices));
            
              revalidateTag("price-list");
              return Response.json({ received: true });
            }
            

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

              Check your understanding

              For each question, ask who waits: the visitor, or nobody.

              The revalidating cached data quiz · 8 questionsScore: first tries count
              1. Question 1 of 8The prerendered /teas revalidates after 10 seconds. A visit made a new copy of it, and the next visit comes 11 seconds later. What does it get?

                Choose an answer to see the explanation.

              2. Question 2 of 8In the recording with cacheLife({ revalidate: 10, expire: 60 }) inside Suspense, what did the visit after the wait do?

                Choose an answer to see the explanation.

              3. Question 3 of 8A webhook calls revalidateTag("price-list", "max"). What does the next visit to /teas get?

                Choose an answer to see the explanation.

              4. Question 4 of 8Which second argument to revalidateTag makes the next visit wait for new data?

                Choose an answer to see the explanation.

              5. Question 5 of 8The supplier's Route Handler calls updateTag("price-list"). What happens?

                Read the code, then predictTypeScript
                import { createHmac, timingSafeEqual } from "node:crypto";
                import { writeFile } from "node:fs/promises";
                import { updateTag } from "next/cache";
                
                function isSigned(body: string, signature: string | null) {
                  if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
                  const expected = createHmac("sha256", process.env.SUPPLIER_SECRET!).update(body).digest();
                  const received = Buffer.from(signature, "hex");
                  return received.length === expected.length && timingSafeEqual(received, expected);
                }
                
                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 });
                  }
                  const prices = JSON.parse(body);
                  await writeFile("data/prices.json", JSON.stringify(prices));
                
                  updateTag("price-list");
                  return Response.json({ received: true });
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 8The webhook calls revalidatePath("/teas"). Which page still shows the old prices?

                Choose an answer to see the explanation.

              7. Question 7 of 8What does this page do when it's built?

                Read the code, then predictTSX
                import { revalidateTag } from "next/cache";
                import { getPrices } from "@/lib/shop";
                
                export default async function TeasPage() {
                  revalidateTag("price-list", "max");
                  const teas = await getPrices();
                  return (
                    <main>
                      <h1>{teas.length} teas</h1>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              8. Question 8 of 8What does refresh() in a Server Action do to cached data?

                Choose an answer to see the explanation.

              Key takeaways

              • A prerendered page past its revalidate is still sent once (STALE) and a new one is started by that request. A later visit gets the new page.
              • Nothing is remade while nobody visits. Past expire, the next request waits.
              • In Next.js 16.4.3, the default in-memory handler drops a hole's cached result once its revalidate has passed, in production, so that request waits.
              • From a webhook, call revalidateTag(tag, "max") so requests within its year-long window needn't wait, or { expire: 0 } when old data must never be shown again. Only the profile's expire counts.
              • updateTag and refresh() only run in Server Actions; no revalidating call runs during render.
              • revalidatePath reaches one page, or every page under a layout with "layout"; a dynamic route needs "page".

              Remember the one-liner.
              Stale copies are sent once and replaced; expired copies make the visitor wait. Choose which one each change needs.

              Read it in Next.js's own code

              These links point at release v16.4.3. A stale page is sent at once and then made again, in lines 389 to 401 of response-cache/index.ts; a page past its expire waits instead, in lines 689 to 700 of incremental-cache/index.ts. The default handler drops entries past revalidate in production, at lines 104 to 125 of default.ts. revalidateTag, updateTag, refresh() and revalidatePath are in lines 37 to 136 of revalidate.ts, and only the profile's expire is kept, at lines 148 to 176 of revalidation-utils.ts. The course's last stage, Under the hood, reads them.

              Coming next: Private and remote caches. "use cache: private" keeps results for one visitor, "use cache: remote" shares them through a store outside the server, and cache handlers decide where cached data lives.

              CompleteFrontend Clear concepts. Working examples.