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.
- 01Follow a copy over timeTell what a visit gets before revalidate, after it, and after expire, and what x-nextjs-cache says each time.
- 02Expire data from outsideCall revalidateTag from a webhook with the right second argument, and know where updateTag and refresh() may run.
- 03Aim 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:
lib/supplier.tsimport { 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();}GET /teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheSTALEx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=10, stale-while-revalidate=3590Content-Typetext/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:
| Value | What it means | Where it was recorded |
|---|---|---|
HIT | The saved page, still fresh, sent as it is. | The visit right after the MISS. |
STALE | The saved page, past its revalidate: sent at once, and a new one is started. | The visit 11 seconds after that. |
MISS | No 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.
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.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
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:
lib/supplier.tsimport { 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();}GET /teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/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:
| When a request comes | A prerendered page | A result in a hole, default handler |
|---|---|---|
| Before revalidate | Sent as it is: x-nextjs-cache: HIT. | Used as it is: the supplier isn't asked. |
| After revalidate, before expire | Sent 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 expire | Can'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:
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.
app/api/supplier/route.tsimport { 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 });}GET /teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheSTALEx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=3600, stale-while-revalidate=82800Content-Typetext/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>"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.
Each row is one version of line 20: what it does to the tag, and what the recorded visit right after got.
| Line 20 | What happens to the price list | The 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. |
"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 getsMint 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:
app/teas/page.tsximport { 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▲ 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: nullLine 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:
| Call | In a Server Action | In a Route Handler | While a page renders |
|---|---|---|---|
revalidateTag(tag, profile) | Yes | Yes | No: the build stops |
updateTag(tag) | Yes | No: it throws, 500 | No |
revalidatePath(path, type?) | Yes | Yes | No |
refresh() | Yes | No: it throws | No |
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.
app/api/supplier/route.tsimport { 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 });}GET /teas/offers200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=3600, stale-while-revalidate=82800Content-Typetext/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>"/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.
Each row is one call, and what each page showed on the visit 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) |
"/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:
app/api/supplier/route.tsimport { 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 });}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=3600, stale-while-revalidate=82800Content-Typetext/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>"/teas/[id]" alone: next start printed a warning that it has no effect, and both teas kept their old prices.
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:
"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:
- 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.
Sort each change by the call that fits it best.
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. UserevalidateTagthere. - "revalidatePath("/teas") refreshes everything under /teas." Only with
"layout". Without it,/teas/offerskept 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.
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?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
cacheLife: {
priceList: { revalidate: 10, expire: 3600 },
},
};
export default nextConfig;
STALE. The page is past its revalidate but not its expire, so the visit gets the old page at once and starts a new one. The recording's header says x-nextjs-cache: STALE.
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?
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();
}
2. The first visit filled the cache, the second used it, and the third, 11 seconds later, found it dropped and asked again. The supplier's log has one more request, from the build.
The supplier sends Mint tea's new price, $4, to this webhook. What does /teas show for Mint tea on the visit right after?
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 });
}
$3, the old price. The visit gets the old page at once, marked STALE, and starts a new one; a later visit shows $4.
After this webhook, /teas/green and /teas/mint still show the old prices. What should line 20 be?
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 });
}
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]", "page");
return Response.json({ received: true });
}
revalidatePath("/teas/[id]", "page"). The type says the path is the dynamic route's page, so every tea's page is expired: the recording shows $5 for green and $4 for mint.
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?
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 });
}
/teas and /teas/offers. Both sit under app/teas/layout.tsx. The home page doesn't, and still said Mint tea at $3.
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?
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 });
}
$4, the new price. The one-argument form prints a deprecation warning and expires the tag at once, so the visit waits for new prices: MISS. Add "max" to keep visits fast, or keep { expire: 0 } on purpose.
Check your understanding
For each question, ask who waits: the visitor, or nobody.
Question 1 of 8The prerendered
/teasrevalidates 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.
Question 2 of 8In the recording with
cacheLife({ revalidate: 10, expire: 60 })insideSuspense, what did the visit after the wait do?Choose an answer to see the explanation.
Question 3 of 8A webhook calls
revalidateTag("price-list", "max"). What does the next visit to/teasget?Choose an answer to see the explanation.
Question 4 of 8Which second argument to
revalidateTagmakes the next visit wait for new data?Choose an answer to see the explanation.
Question 5 of 8The supplier's Route Handler calls
updateTag("price-list"). What happens?Read the code, then predictTypeScriptimport { 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.
Question 6 of 8The webhook calls
revalidatePath("/teas"). Which page still shows the old prices?Choose an answer to see the explanation.
Question 7 of 8What does this page do when it's built?
Read the code, then predictTSXimport { 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.
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. updateTagandrefresh()only run in Server Actions; no revalidating call runs during render.revalidatePathreaches 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.
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.