Incremental Static Regeneration
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were
checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers
the App Router in depth and the Pages Router in its own section, because ISR was born there. Caching decides
everything on this page, so every example says which model it uses: the previous model (the default, in which
nothing is cached unless you ask, with revalidate segment config and tagged fetch) or the opt-in Cache Components
model (cacheComponents: true, with 'use cache', cacheLife and cacheTag). Every example was built with
next build, started with next start and observed with curl on Next.js 16.3.8 and Node.js 24.16 on macOS, in a
scratch copy of the Bookshelf that the rest of this section grows; each is followed by the official page it derives
from, and every timing and header quoted below is a real measurement from those runs.
Rendering Fundamentals introduced ISR as one stop on the rendering spectrum, and Server and Client Components showed what is prerendered at build time. This page is the full treatment: what ISR is, how the two caching models implement it, every way to trigger it, what a CDN and a fleet of servers do to it, and what it costs.
What ISR Is, and Why It Exists
A statically generated page is fast because it is a file: no query, no render, one cache lookup. Its weakness is that a change in the data needs a new build. For a catalogue of three books that is acceptable; for a catalogue of one hundred thousand products it is not. Incremental Static Regeneration keeps the speed of static files and removes the rebuild:
-
Update static content without rebuilding the whole site.
-
Serve prerendered pages for almost every request, so the server and the database stay idle.
-
Get the right
Cache-Controlheaders on those pages automatically. -
Keep
next buildshort by prerendering only some pages and generating the rest on first request.
The mechanism is stale-while-revalidate, the same idea as the HTTP directive of that name. A cache entry is fresh until its revalidation time has passed. After that it is stale: the next request still gets the stale copy immediately, and that same request starts building a new copy in the background. When the new copy is ready it replaces the old one, and requests after that get the new copy. Nobody ever waits for a render, except for the very first visit to a page that was never generated, and after the entry has expired (see below).
Two details of the diagram matter in practice. First, regeneration produces both artifacts of a route, the HTML for the first load and the RSC payload that the client router uses for navigations (see Server and Client Components), and stores them in the same entry so that a hard load and a client navigation never disagree. Second, nothing in this loop is a timer: regeneration is triggered by a request. A page nobody visits is never regenerated, which is why a revalidation time is "at most once every N seconds", not "every N seconds".
An entry has three lifetimes, and the names differ between the two models but the shape is the same:
The revalidate time says when a copy turns stale. The expire time says when a stale copy is too old to serve at
all, so the next request blocks on a render instead. The default expire is one year, which in practice means "never
block". The x-nextjs-cache response header of a self-hosted next start server shows where an entry is: HIT
(served from cache), STALE (served from cache while a regeneration runs), MISS (not in the cache, rendered now) and
REVALIDATED (regenerated by on-demand revalidation). Treat it as a diagnostic, not as an API.
Where ISR Runs
ISR is a feature of a long-running Node.js server (or of a platform that emulates one), not of the build and not of the browser. The matrix below is the part that surprises people who come from static hosting.
| Stage | What happens | Notes |
|---|---|---|
|
Every route that |
The Pages Router has its own |
Request, on the server that receives it |
The cache is read. A stale entry is served and a regeneration starts on that instance. |
The work counts as extra compute on platforms that bill per request. |
Proxy |
Not executed for on-demand ISR requests. Rewrites and logic in |
Revalidate the real path ( |
|
Pages are always rendered on demand and never cached. |
Verify ISR with |
Edge runtime, static export |
Not supported. ISR needs the Node.js runtime; |
Platforms that run |
Browser |
The client router keeps its own copy for a while (the |
That cache is separate from the server cache described here. |
Two rules follow. Verify with a production build: next build followed by next start is the only way to see
STALE and HIT. And the cache lives where the server lives: with one instance and a persistent disk it just works,
with several instances or ephemeral compute you must plan for it (see Self-Hosting and Multiple Instances).
The Running Example: A Bookshelf Behind a CMS
The Bookshelf keeps its book data in a CMS. The front end shows /books/1, /books/2, … and a /shelf page that
lists everything. Editors change prices; the site must follow within seconds, but a visitor must never wait for the
CMS. That is ISR. To keep the experiments reproducible, the CMS is replaced by a JSON file that "an editor" changes
behind the application’s back.
// lib/store.ts
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
export type Entry = { title: string; priceCents: number }
export async function readStore(): Promise<Record<string, Entry>> {
return JSON.parse(await readFile(join(process.cwd(), 'data', 'books.json'), 'utf8'))
}
{
"1": { "title": "Dune", "priceCents": 999 },
"2": { "title": "Neuromancer", "priceCents": 899 },
"3": { "title": "Solaris", "priceCents": 799 }
}
The application reads it over HTTP, like it would read a real CMS, through a Route Handler. connection() makes the
handler dynamic under both caching models (without it, Cache Components would prerender a GET handler that touches no
request data).
// app/api/cms/books/[id]/route.ts
import { connection } from 'next/server'
import { readStore } from '@/lib/store'
export async function GET(_req: Request, ctx: RouteContext<'/api/cms/books/[id]'>) {
await connection()
const { id } = await ctx.params
const book = (await readStore())[id]
return book ? Response.json({ id, ...book }) : new Response(null, { status: 404 })
}
// app/api/cms/books/route.ts (the list used by /shelf)
import { connection } from 'next/server'
import { readStore } from '@/lib/store'
export async function GET() {
await connection()
const db = await readStore()
return Response.json(Object.entries(db).map(([id, b]) => ({ id, ...b })))
}
Because next build calls the CMS while it prerenders, the CMS must answer during the build. A real, hosted CMS does.
For the local experiment a 12-line Node script served the same JSON on the port of the later next start for the
duration of the build; the running server then answered the same URL from the Route Handler above. Editing a price is
one line of script:
// setprice.mjs: node setprice.mjs 1 1099 (what an editor saving in the CMS looks like)
import { readFileSync, writeFileSync } from 'node:fs'
const [id, cents] = process.argv.slice(2)
const file = new URL('./data/books.json', import.meta.url)
const db = JSON.parse(readFileSync(file, 'utf8'))
db[id].priceCents = Number(cents)
writeFileSync(file, JSON.stringify(db, null, 2))
And a small shell helper prints one line per request: the time, the status, the x-nextjs-cache header, the price and
the "Rendered at" stamp of the page. Reading the stamp is the whole trick: the same stamp means the same copy. The time column is in UTC, like the stamps.
# h.sh /books/1
H=$(curl -s -D - -o /tmp/body.html "http://localhost:3503$1")
echo "$(date -u +%T) $(echo "$H" | head -1 | cut -d' ' -f2) \
$(echo "$H" | grep -i '^x-nextjs-cache' | cut -d' ' -f2 | tr -d '\r') | \
$(sed 's/<!-- -->//g' /tmp/body.html | grep -o -E 'Price: [0-9.]+ EUR|Rendered at [0-9TZ:.-]+' | tr '\n' ' ')"
The Previous Model: revalidate, fetch Options and unstable_cache
Without cacheComponents (the default in 16.3), a page is static if nothing in it needs the request, and ISR is
switched on by giving it a lifetime. There are three ways to do that and they combine.
Route Segment Config revalidate
// app/books/[id]/page.tsx (previous model)
import { notFound } from 'next/navigation'
import { getBook } from '@/lib/cms'
// Regenerate at most once every 5 seconds. A long value (3600) is more realistic;
// 5 keeps the experiment short. Must be a literal number.
export const revalidate = 5
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }] // prerendered at build time
}
export default async function BookPage({ params }: PageProps<'/books/[id]'>) {
const { id } = await params
const book = await getBook(id)
if (!book) notFound()
return (
<main>
<h1>{book.title}</h1>
<p>Price: {(book.priceCents / 100).toFixed(2)} EUR</p>
<p>Rendered at {new Date().toISOString()}</p>
</main>
)
}
// lib/cms.ts (previous model): fetch with tags, no cache option of its own
export type Book = { id: string; title: string; priceCents: number }
const CMS = process.env.CMS_URL ?? 'http://localhost:3503/api/cms'
export async function getBook(id: string): Promise<Book | null> {
const res = await fetch(`${CMS}/books/${id}`, {
next: { tags: ['books', `book:${id}`] },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`CMS answered ${res.status}`) // keeps the last good page
return res.json()
}
export async function listBooks(): Promise<Book[]> {
const res = await fetch(`${CMS}/books`, { next: { tags: ['books'] } })
if (!res.ok) throw new Error(`CMS answered ${res.status}`)
return res.json()
}
The build prints the lifetimes in the route table (the Revalidate and Expire columns appear when a route has a
lifetime):
Route (app) Revalidate Expire
├ /books/[id]
│ ├ ● /books/1 5s 1y
│ └ ● /books/2 5s 1y
...
● (SSG) prerendered as static HTML (uses generateStaticParams)
Now the stale-then-fresh cycle, with real output. The server was started with next start; the price in the CMS was
changed after the page was cached:
$ node setprice.mjs 1 999 # CMS and page agree
14:19:13 200 HIT | Price: 9.99 EUR Rendered at 2026-10-03T14:19:13.318Z
$ node setprice.mjs 1 1099 # the editor saves; six seconds pass
14:19:19 200 STALE | Price: 9.99 EUR Rendered at 2026-10-03T14:19:13.318Z # stale copy, regeneration starts
14:19:20 200 HIT | Price: 10.99 EUR Rendered at 2026-10-03T14:19:19.399Z # the regenerated copy
Read it like the sequence diagram: the visitor who arrives after the lifetime still sees 9.99 with the old stamp
(STALE); that request started the regeneration, whose result carries the new stamp 14:19:19.399; the next visitor
sees 10.99. The response headers of the same page:
Cache-Control: s-maxage=5, stale-while-revalidate=31535995
That is the contract with a CDN: for 5 seconds the page may be served without asking; after that it may be served
stale for another 31 535 995 seconds (one year minus five seconds, because expire defaults to a year) while it is
refreshed. expireTime and CDN Headers shows how to change the second number.
Source: ISR, Route segment config: revalidate
The rules of revalidate
-
The value is
false(the default: cache indefinitely, equivalent toInfinity),0(always render dynamically, even when no Request-time API or uncached fetch is found) or a number of seconds. -
It must be statically analysable:
revalidate = 600works,revalidate = 60 * 10does not. A build that contains the latter fails withInvalid segment configuration export detected, with nothing else in the log to point at it. -
The lowest value across the layouts, the page and the
fetchcalls of one route wins, so a child page is regenerated at least as often as its parent layout. Afetchwithnext.revalidatelower than the route’s value lowers the route’s value. -
A
fetchwithrevalidate: 0orcache: 'no-store'makes the route dynamic: it is rendered per request and ISR does not apply. -
The segment value does not override the
revalidateof an individualfetch; it is only their default.
fetch With next.revalidate and next.tags
The segment config is a route-wide default. The fetch options are per request and need no segment config at all:
// app/promo/page.tsx (previous model): the fetch alone gives the route its lifetime
import type { Book } from '@/lib/cms'
export default async function Promo() {
const res = await fetch('http://localhost:3503/api/cms/books/2', {
next: { revalidate: 5 }, // false, 0 or a number of seconds
})
const book: Book = await res.json()
return (
<main>
<p>Today: {book.title}: {(book.priceCents / 100).toFixed(2)} EUR</p>
<p>Rendered at {new Date().toISOString()}</p>
</main>
)
}
The route table shows /promo 5s 1y, and the run is the same cycle (HIT, then STALE with the old price after the
editor changed it, then HIT with 7.77 EUR after the regeneration). next.tags attaches names to the cached
response so it can be invalidated by name later (see On-Demand Revalidation); a tag is at most 256 characters and a route at
most 128 of them.
unstable_cache for Data That Is Not a fetch
A database or ORM call has no fetch to put options on. unstable_cache wraps the function and gives it a key
prefix, a lifetime and tags (the name still carries unstable_ in 16.3, as the caching guide for the previous model
documents it):
// app/stats/page.tsx (previous model)
import { unstable_cache } from 'next/cache'
import { readStore } from '@/lib/store'
const getTotal = unstable_cache(
async () => {
const db = await readStore()
return Object.values(db).reduce((sum, b) => sum + b.priceCents, 0)
},
['catalogue-total'], // key prefix
{ revalidate: 5, tags: ['books'] }, // lifetime and tags
)
export default async function Stats() {
const total = await getTotal()
return (
<main>
<p>Total: {(total / 100).toFixed(2)} EUR</p>
<p>Rendered at {new Date().toISOString()}</p>
</main>
)
}
The route table shows /stats 5s 1y. After the CMS changed, the first request after the lifetime returned the old
total with x-nextjs-cache: STALE, the next one the new total; and a tag invalidation of books (see below) also
refreshed it, because the function carries the tag books.
Unknown Paths: dynamicParams
generateStaticParams lists what is prerendered at build time. What happens to /books/3, which was not listed, is
decided by the dynamicParams segment config. Observed on the build above:
| Request | Result | Why |
|---|---|---|
|
|
Prerendered at build time. |
|
|
|
|
|
|
|
|
Anything that |
// app/strict/[id]/page.tsx (previous model)
export const dynamicParams = false // ids not returned by generateStaticParams answer 404
export const revalidate = 5
export async function generateStaticParams() {
return [{ id: '1' }]
}
dynamicParams is the App Router replacement for the fallback option of getStaticPaths, and it is not available
under Cache Components (see The Cache Components Model: 'use cache' and cacheLife).
Source: dynamicParams, generateStaticParams
The Cache Components Model: 'use cache' and cacheLife
With cacheComponents: true the unit of caching moves from the route to the function or component. The route
segment revalidate is no longer allowed (nor is dynamic or dynamicParams): the build stops with
Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.
(dynamicParams produces the same message). The lifetime now lives next to the data, through the 'use cache'
directive, cacheLife and cacheTag. The page itself becomes a partially prerendered shell: the parts that are
cached or static are in the shell, everything else streams in (see Streaming and Suspense).
// next.config.ts: Cache Components with one custom profile
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
editorial: {
stale: 60, // the client may reuse it for 60 s without asking the server
revalidate: 5, // after 5 s a request triggers a background regeneration
expire: 3600, // after 1 h without traffic the next request waits for a render
},
},
}
export default nextConfig
// lib/cms.ts (Cache Components)
import { cacheLife, cacheTag } from 'next/cache'
export type Book = { id: string; title: string; priceCents: number; renderedAt: string }
const CMS = process.env.CMS_URL ?? 'http://localhost:3503/api/cms'
export async function getBook(id: string): Promise<Book | null> {
'use cache'
cacheLife('editorial') // the lifetime, next to the data
cacheTag('books', `book:${id}`) // names for on-demand invalidation
const res = await fetch(`${CMS}/books/${id}`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`CMS answered ${res.status}`) // keeps the last good entry
return { ...(await res.json()), renderedAt: new Date().toISOString() }
}
Date is allowed inside a 'use cache' function because the result is cached on purpose, so the stamp is the time of
the cache entry, which is what the experiments below read. The page reads params inside a <Suspense> boundary, as
Cache Components requires for routes whose params are not all known:
// app/books/[id]/page.tsx (Cache Components)
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getBook } from '@/lib/cms'
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }]
}
async function BookDetails({ params }: Pick<PageProps<'/books/[id]'>, 'params'>) {
const { id } = await params // read inside the boundary so the shell does not depend on the id
const book = await getBook(id)
if (!book) notFound()
return (
<main>
<h1>{book.title}</h1>
<p>Price: {(book.priceCents / 100).toFixed(2)} EUR</p>
<p>Rendered at {book.renderedAt}</p>
</main>
)
}
export default function BookPage(props: PageProps<'/books/[id]'>) {
return (
<Suspense fallback={<p>Loading book...</p>}>
<BookDetails params={props.params} />
</Suspense>
)
}
Route (app) Revalidate Expire
├ /books/[id]
│ ├ ◐ /books/[id]
│ ├ ○ /books/1 5s 1h
│ └ ○ /books/2 5s 1h
└ ○ /shelf 5s 1h
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
The extra ◐ /books/[id] row is the App Shell of the route, the generic part of the page that does not depend on the
id. The stale-while-revalidate cycle is identical to the previous model, with the lifetimes now coming from the
profile (expire: 3600 shows up as stale-while-revalidate=3595):
14:23:10 200 STALE | Price: 9.99 EUR Rendered at 2026-10-03T14:23:04.538Z
14:23:11 200 HIT | Price: 10.99 EUR Rendered at 2026-10-03T14:23:10.606Z
The cacheLife Profiles
A profile has three numbers, which map one to one onto the ISR vocabulary:
| Property | Meaning | ISR equivalent |
|---|---|---|
|
How long the client router may reuse the data without asking the server. Sent in the |
Nothing in the previous model ( |
|
After this time, a request is served the cached entry and triggers a background regeneration. |
|
|
After this time without a request, the next request waits for a full render. Must be longer than |
The expire column of the route table, |
Omitted properties inherit from the default profile, and so does cacheLife({}). The presets (a profile name is
passed to cacheLife):
| Profile | Use for | stale |
revalidate |
expire |
|---|---|---|---|---|
|
Standard content |
5 minutes |
15 minutes |
never |
|
Real-time data |
30 seconds |
1 second |
1 minute |
|
Frequently updated content |
5 minutes |
1 minute |
1 hour |
|
Updated several times a day |
5 minutes |
1 hour |
1 day |
|
Updated daily |
5 minutes |
1 day |
1 week |
|
Updated weekly |
5 minutes |
1 week |
30 days |
|
Rarely changes |
5 minutes |
30 days |
1 year |
You can use a preset, define a custom profile in next.config.ts (as editorial above), redefine a preset (document
that you did, because days reads as "about 24 hours" to the next developer) or pass an inline object for a one-off:
cacheLife({ stale: 60, revalidate: 300, expire: 3600 }). cacheLife can only be called inside a cache scope, never at
module level; call it in the same function that carries 'use cache', and make sure only one call runs per invocation.
The profile also decides where the result can live. A short lifetime turns a cached value into a dynamic hole:
-
revalidate: 0, or anexpireunder 5 minutes: excluded from prerenders, resolved at request time. Of the presets onlysecondsfalls under this. -
staleunder 30 seconds: excluded from prerenders (a prefetch would expire before the user could click). -
stalebetween 30 seconds and 5 minutes: prerendered, but not part of the route’s App Shell.
Source: cacheLife, cacheLife (config), use cache
App Shell ISR for Unknown Params (16.3)
Before 16.3, a visit to a URL that generateStaticParams did not list under Cache Components waited for a full server
render. In 16.3, with partialPrefetching enabled next to cacheComponents, such a visit gets the App Shell
instantly and the params-specific content streams in; afterwards the route is generated in the background with the
now-known params and later visits get the upgraded result. It is the Cache Components equivalent of fallback: true
plus ISR.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true, // requires cacheComponents; introduced in 16.3.0
}
export default nextConfig
With the page above (listed ids 1 and 2), three requests to the unlisted /books/3 showed the whole cycle:
$ curl -sD - localhost:3503/books/3 # first visit: the App Shell, then the content
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
# body: <template id="B:0"> with "Loading book..." first, then <div hidden id="S:0"> with "Price: 7.99 EUR"
$ curl -sD - localhost:3503/books/3 # second visit: the upgraded entry
x-nextjs-cache: HIT
Cache-Control: s-maxage=5, stale-while-revalidate=3595
# Price: 7.99 EUR Rendered at 2026-10-03T14:23:38.618Z (same stamp on the third visit)
The first response was not cacheable (private, no-cache): it carried the fallback and streamed the content in the
same response. From the second request on the entry was an ordinary ISR entry with s-maxage=5. Without
partialPrefetching the same unlisted URL was rendered dynamically on every request (private, no-cache each time,
although the 'use cache' data inside it was cached, as the identical stamp in two requests showed); with it, the
upgrade makes the route static after its first visit.
What the upgrade produces depends on what the render touches:
-
every access cached and all params resolved: a fully static page;
-
all params resolved but uncached data or
cookies()/headers()behind<Suspense>: a cached page with those fallbacks, the dynamic parts streaming at request time; -
params are resolved in route order, so a param that
generateStaticParamsdoes not return stays unresolved and stops any deeper param from upgrading.
A prefetch counts as the first visit: a <Link> to an unlisted URL that enters the viewport, or router.prefetch,
starts the upgrade before the click (see Linking and Navigation). Read
params inside a <Suspense> boundary, even for the ids you list, because a statically known param still belongs to
one URL and awaiting it above the boundary would tie the shell to that URL. Choose what to prerender deliberately:
every prerendered page costs build time and storage and many are never requested before the next deployment, so list
the popular and predictable ones and let the rest upgrade after their first visit.
On-Demand Revalidation
Time-based ISR is a guess about how often data changes. When you know (a Server Action ran, a CMS webhook arrived) you
invalidate on demand. Five functions exist; they live in next/cache (refresh too) and differ in where they may be
called, what they target, and, crucially, whether the next visitor waits.
| Function | Callable from | Targets | The next request |
|---|---|---|---|
|
Server Functions, Route Handlers |
Every entry carrying the tag, across all pages |
Served the stale entry while regeneration runs (stale-while-revalidate). |
|
Server Functions, Route Handlers |
Same |
Stale content may be served for at most |
|
Server Actions only |
Same |
Waits for fresh data: read-your-own-writes, no stale copy. |
|
Server Functions, Route Handlers |
One page, or every page matching a pattern, or a layout and everything beneath it |
Marked for regeneration; measured below: blocks until fresh, no stale copy. |
|
Server Actions only |
The client router cache of the current page |
Does not touch the server data cache at all. |
None of them can be called from a Client Component or from Proxy. A revalidation is triggered by a request, not by the
call: invalidating a tag marks entries, and each page regenerates when it is next visited (a revalidatePath with a
dynamic pattern does not run hundreds of renders at once).
revalidateTag: The Default Choice
revalidateTag(tag, profile) takes two arguments since 16; the one-argument form is deprecated (it still works if
TypeScript errors are suppressed, as { expire: 0 }). The recommended profile is 'max': a one-year window, so the
request after the call is always served stale content while the entry is rebuilt. Any profile from cacheLife
('hours', a custom one) or an object { expire: seconds } can replace it when you want stale content to stop being
served after some time. Observed on the page above (the entry was fresh when the tag was invalidated, and the CMS had
changed in between):
$ revalidateTag('book:1', 'max') # previous model, CMS now says 10.99
14:20:02 200 STALE | Price: 9.99 EUR Rendered at 2026-10-03T14:19:58.375Z # stale served; regeneration starts
14:20:02 200 STALE | Price: 9.99 EUR Rendered at 2026-10-03T14:19:58.375Z # same second: still regenerating
14:20:08 200 STALE | Price: 10.99 EUR Rendered at 2026-10-03T14:20:02.119Z # the regenerated copy, six seconds old
$ revalidateTag('book:1', { expire: 0 }) # previous model
14:20:08 200 MISS | Price: 11.99 EUR Rendered at 2026-10-03T14:20:08.908Z # this request waited for the render
14:20:08 200 HIT | Price: 11.99 EUR Rendered at 2026-10-03T14:20:08.908Z
The first request after a max invalidation is stale even though the entry was still within its 5-second lifetime:
on-demand invalidation overrides the clock. Note that a second request in the same second still saw the stale copy: the regeneration had not finished yet, which
is normal for stale-while-revalidate (by the time of the third line the new copy was itself older than 5 seconds, so it
was stale by its normal lifetime).
Under Cache Components the same calls behave the same way ('hours' as the profile, observed with STALE then HIT),
the data coming from the tags set by cacheTag.
updateTag: Read Your Own Writes
When the user who made the change is the user who will look at the page next (a form, an editor’s "Publish" button),
stale content is wrong: they would press Save and still see the old price. updateTag expires the tag immediately, so
the next render waits for fresh data. It exists only inside Server Actions; calling it from a Route Handler throws:
Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other
contexts, use revalidateTag instead.
// app/admin/actions.ts
'use server'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { refresh, updateTag } from 'next/cache'
import { readStore } from '@/lib/store'
export async function publish(formData: FormData) {
// A real application checks the session and validates the input here
// (see the security pages of this section).
const id = String(formData.get('id'))
const priceCents = Number(formData.get('priceCents'))
const db = await readStore()
if (!db[id] || !Number.isInteger(priceCents)) throw new Error('invalid input')
db[id].priceCents = priceCents
await writeFile(join(process.cwd(), 'data', 'books.json'), JSON.stringify(db, null, 2))
// Read-your-own-writes: the next render waits for fresh data instead of serving stale.
updateTag(`book:${id}`)
}
export async function refreshAction() {
// Refreshes the client router cache for the current page; does not touch the server data cache.
refresh()
}
// app/admin/page.tsx
import { publish, refreshAction } from './actions'
export default function Admin() {
return (
<>
<form action={publish}>
<input type="hidden" name="id" value="1" />
<input name="priceCents" defaultValue="1099" />
<button type="submit">Publish</button>
</form>
<form action={refreshAction}>
<button type="submit">Refresh</button>
</form>
</>
)
}
The forms were posted with curl (the hidden $ACTION_ID_… field of the rendered form is the action’s identifier,
and the Origin header must be sent). After publish with a new price:
$ curl -X POST localhost:3503/admin -H 'Origin: http://localhost:3503' -F "$AID=" -F id=1 -F priceCents=2222
action -> 200
14:20:54 200 MISS | Price: 22.22 EUR Rendered at 2026-10-03T14:20:54.254Z # previous model: waited, fresh
14:20:54 200 HIT | Price: 22.22 EUR Rendered at 2026-10-03T14:20:54.254Z
# Cache Components: same price 33.33 EUR, first response Cache-Control private, no-cache (rendered per request),
# then HIT with s-maxage=5
updateTag works in both caching models. refresh() refreshes the client router from within a Server Action; posted
from the second form it returned 200 and left the server data cache alone (the data cache is what updateTag,
revalidateTag and revalidatePath act on). When a Server Action calls any of
revalidateTag, revalidatePath, updateTag or refresh, the entire client router cache is cleared immediately,
bypassing the stale time.
revalidatePath: Pages and Layouts
revalidatePath(path, type?) invalidates by route rather than by data. path is either a literal path
('/books/1', one page, type omitted) or a route pattern ('/books/[id]', which requires type). Do not append
/page or /layout; use type for that. It must not exceed 1024 characters and is case-sensitive.
import { revalidatePath } from 'next/cache'
revalidatePath('/books/1') // one specific page
revalidatePath('/books/[id]', 'page') // every page matching the pattern, not the ones beneath it
revalidatePath('/books/[id]', 'layout') // that layout, all nested layouts and pages beneath it
revalidatePath('/(main)/books/[id]', 'page') // route groups are part of the path
revalidatePath('/', 'layout') // everything: also purges the client router cache
Measured on the previous-model build:
$ revalidatePath('/books/1') # literal path
14:20:09 200 MISS | Price: 12.99 EUR Rendered at 2026-10-03T14:20:08.988Z # blocked, fresh
$ revalidatePath('/books/[id]', 'page') # pattern with type
14:20:10 200 MISS | Price: 9.99 EUR (book 2, new stamp) # both pages regenerate
14:20:10 200 MISS | Price: 13.99 EUR (book 1, new stamp) # on their next visit
$ revalidatePath('/books/[id]') # pattern without type: nothing happens, and the server logs
Warning: a dynamic page path "/books/[id]" was passed to "revalidatePath", but the "type" parameter is missing.
This has no effect by default
Under Cache Components the same call produced a first response rendered per request (private, no-cache, fresh price
66.66) and then a HIT. So revalidatePath blocks the next visitor, unlike revalidateTag(tag, 'max'); prefer tags
for content that tolerates a short stale window.
Four more rules matter in practice:
-
Pass the real path, not a rewritten one. With a rewrite from
/blogto/news, revalidate/news: cache entries are tagged by the route file that rendered them. -
Path and tag are independent.
revalidatePath('/blog')refreshes the blog page only. A dashboard page that shows the same data under the tagpostskeeps its old copy until the tag is invalidated too. Mutations usually call both:revalidatePath('/blog')andupdateTag('posts'). -
revalidatePathalso invalidates the GET Route Handler at that path when it caches data (for example withfetch(…, { cache: 'force-cache' })). -
In a Server Function it updates the UI at once if the user is looking at the path; in a Route Handler it only marks the path, and the regeneration happens on the next visit.
Source: revalidateTag, updateTag, revalidatePath, refresh
Webhook-Triggered Revalidation From a CMS
The usual producer of "I know the data changed" is not your application but a CMS: the editor presses Publish and the
CMS calls a URL of yours. That URL is a public endpoint that changes server state, so treat it like one: verify who
is calling, accept only identifiers you can validate, and never let the payload name arbitrary tags or paths. The
handler below checks an HMAC-SHA256 signature of the raw body (most CMS products sign webhooks this way, or send a
shared-secret header), compares it in constant time, validates the id, and invalidates tags the application defines.
updateTag is unavailable here, so it uses revalidateTag(tag, 'max').
// app/api/revalidate/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'
type CmsEvent = { type: 'book.updated' | 'book.published'; id: string }
export async function POST(request: Request) {
const secret = process.env.CMS_WEBHOOK_SECRET
if (!secret) return Response.json({ error: 'webhook not configured' }, { status: 500 })
// The CMS signs the raw body with HMAC-SHA256 and sends it in x-signature (hex).
const raw = await request.text()
const given = Buffer.from(request.headers.get('x-signature') ?? '')
const expected = Buffer.from(createHmac('sha256', secret).update(raw).digest('hex'))
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return Response.json({ error: 'invalid signature' }, { status: 401 })
}
const event = JSON.parse(raw) as CmsEvent
if (!/^\d{1,10}$/.test(event.id)) {
return Response.json({ error: 'bad id' }, { status: 400 })
}
// Only tags this application defines: never take them from the payload verbatim.
revalidateTag(`book:${event.id}`, 'max')
if (event.type === 'book.published') revalidateTag('books', 'max') // the list page too
return Response.json({ revalidated: true, now: Date.now() })
}
Keep the secret in an environment variable on the server (see
Configuration, TypeScript and Environment Variables),
never with a NEXT_PUBLIC_ prefix. The experiment signed the body the way the CMS would and used the /shelf page,
which has no lifetime of its own (it is static and changes only on invalidation):
BODY='{"type":"book.published","id":"2"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CMS_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -s -X POST http://localhost:3503/api/revalidate -H "x-signature: $SIG" -d "$BODY"
14:19:27 200 HIT | Dune: 9.99 EUR Neuromancer: 8.99 EUR Rendered at 2026-10-03T14:18:38.578Z
book 2 is now 9.49 EUR in the CMS: nothing changes on /shelf, it is static ("no hook yet"; Dune's 10.99 below is an
earlier edit that the cached page did not show either)
14:19:29 200 HIT | Dune: 9.99 EUR Neuromancer: 8.99 EUR Rendered at 2026-10-03T14:18:38.578Z
POST with a wrong signature -> {"error":"invalid signature"} -> 401
POST with the correct signature -> {"revalidated":true,"now":1791037169194} -> 200
14:19:29 200 STALE | Dune: 9.99 EUR Neuromancer: 8.99 EUR Rendered at 2026-10-03T14:18:38.578Z
14:19:29 200 HIT | Dune: 10.99 EUR Neuromancer: 9.49 EUR Rendered at 2026-10-03T14:19:29.208Z
The page was served from cache until the webhook arrived, then once stale, then fresh; it never blocked a visitor. Two
design points: the /shelf list carries the tag books, so a book.published event refreshes it while a
book.updated event refreshes only the page of that book; and the handler returns quickly, because the CMS usually
retries slow or failing webhooks.
expireTime and CDN Headers
Next.js writes Cache-Control for you according to the kind of route, and a CDN can use it:
| Route | Cache-Control |
Observed |
|---|---|---|
Static, no lifetime |
|
|
ISR |
|
|
Dynamic |
|
The first visit of an unlisted route under Cache Components. |
|
|
Hashed file names; cannot be overridden. |
The second number of the ISR value comes from the expire time. Under Cache Components it is the profile’s expire
(3600 gave stale-while-revalidate=3595). In the previous model it defaults to one year and can be lowered for every
ISR page with the top-level expireTime option in next.config (seconds):
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
expireTime: 3600, // one hour, in seconds
}
export default nextConfig
Built with that setting, the page with revalidate = 5 answered Cache-Control: s-maxage=5, stale-while-revalidate=3595
(an hour minus the 5 seconds), and the Pages Router table printed /legacy/[id] 5s 1h in the Expire column. The
official example: a revalidate of 15 minutes with an expire time of one hour gives s-maxage=900, stale-while-revalidate=2700.
A CDN in front of Next.js has three jobs to get right:
-
It must honour
s-maxageandstale-while-revalidate, and cache the HTML and the RSC payload of a route together, with the same lifetime and invalidation, otherwise a client navigation can show different content from a page load. App Router responses vary on thersc,next-router-state-tree,next-router-prefetch,next-router-segment-prefetchand (for interception routes)next-urlrequest headers; Next.js sendsVaryfor them and adds an_rscquery parameter that hashes them for CDNs that ignoreVary. The CDN must keep therscheader and the_rscparameter in its cache key. -
It does not know about on-demand revalidation.
revalidateTagandrevalidatePathinvalidate the Next.js server cache; the CDN keeps serving its own copy untils-maxageruns out. Purge the CDN yourself right after the Next.js call, for both the HTML and the RSC variants of the affected URLs. -
Proxy must run before the cache. Redirects, rewrites and authentication decisions in
proxy.tsshould come first; if the CDN sits in front of them, bypass caching for the routes that depend on them (see Redirects, Rewrites and Proxy).
Rolling deployments add version skew (a client of deploy A talking to a server of deploy B). Setting deploymentId
makes the client do a hard navigation when the server’s deployment differs, instead of mixing payloads.
Source: expireTime, CDN caching, How revalidation works
Self-Hosting and Multiple Instances
ISR and the fetch data cache use the same server cache. By default it is an in-memory store (50 MB) backed by the
local disk of each instance. That is perfect for one next start process with a persistent disk. It breaks three
assumptions as soon as you scale:
-
Ephemeral compute (most serverless setups, containers without a volume): the disk is not persistent, so the cache is lost on restart and is per instance.
-
Several instances behind a load balancer (Kubernetes pods, containers): each instance has its own cache. Different visitors can see different content depending on which instance answers, and
revalidateTagorrevalidatePathinvalidates only the instance that received the call. -
Background regeneration runs on the instance that got the triggering request, and on platforms that bill per request it counts as extra compute.
The remedy is a shared cache plus shared tag state. There are two handler options, for two different caches:
| Option | Used for | Methods |
|---|---|---|
|
The server cache: ISR pages, Route Handler responses, |
|
|
|
|
For Cache Components, the two hooks that matter for coordination are updateTags(), called when revalidateTag()
runs and which should write the invalidation event to shared storage (Redis, a database), and refreshTags(), called
before each new request and which should read recent events back and update local tag state. Catch errors inside
refreshTags(): if it throws, the request fails; if it catches, requests continue on the last known state and may
serve stale content until connectivity returns. A cache read failure must be signalled by returning undefined
(a cache miss), not by throwing. Soft tags (names such as N_T/books/[id]/page, generated from the route path)
are how revalidatePath rides on the same tag mechanism.
The code below is a runnable cacheHandler skeleton in the shape of the reference, with one lesson learned by running
it on 16.3.8: page entries do not carry their tags in ctx.tags. A handler that indexes only ctx.tags, as a
quick reading of the guide suggests, never invalidates a page. The tags of an APP_PAGE value travel in the
x-next-cache-tags header of the value (a comma-separated string), for the page of book 1:
N_T/layout,N_T/books/layout,N_T/books/[id]/layout,N_T/books/[id]/page,N_T/books/1,books,book:1.
// cache-handler.js: a single-process stand-in for a shared store (Redis, DynamoDB, S3...)
const store = new Map()
// Fetch entries carry their tags in ctx.tags; page entries carry them in the
// x-next-cache-tags header of the value (a comma separated string).
function tagsOf(data, ctx) {
if (ctx?.tags?.length) return ctx.tags
const header = data?.headers?.['x-next-cache-tags']
return typeof header === 'string' ? header.split(',') : []
}
module.exports = class CacheHandler {
constructor(options) {
this.options = options
}
async get(key) {
return store.get(key) ?? null // null (or undefined) is a miss
}
async set(key, data, ctx) {
store.set(key, { value: data, lastModified: Date.now(), tags: tagsOf(data, ctx) })
}
async revalidateTag(tags) {
const list = [tags].flat()
for (const [key, entry] of store) {
if (entry.tags.some((t) => list.includes(t))) store.delete(key)
}
}
resetRequestCache() {}
}
// next.config.ts
import type { NextConfig } from 'next'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const nextConfig: NextConfig = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // do not keep a second, per-instance copy
}
export default nextConfig
Run with it, the page behaved as before: MISS on the first request (the build-time seed is not in the handler’s
store), HIT on the next, the webhook for book:1 deleted the entry through revalidateTag(["book:1"]) and the
following request regenerated it (MISS, new price) and was HIT again. Two caveats from that run: this handler
deletes on invalidation, so the max profile’s stale-while-revalidate becomes a blocking render (a production handler
keeps the entry and marks it stale to preserve it), and a Map is not shared, so for several instances the store
must be an external service.
Other parts of a multi-instance deployment that bite ISR users:
-
All instances must run the same build. If you rebuild per environment, give
generateBuildIda stable value (a git hash); whendeploymentIdis set, Next.js uses a constant build id andgenerateBuildIdhas no effect. -
Server Actions need one encryption key across instances (
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, base64, 16, 24 or 32 bytes), or an action encrypted by one instance fails on another with "Failed to find Server Action". -
With a reverse proxy such as nginx in front, disable response buffering (
X-Accel-Buffering: no), because a streamed first visit of an App Shell route needs it (see Streaming and Suspense). -
On Kubernetes, give each pod a shared cache handler instead of relying on pod-local state (see Kubernetes, Docker and Redis for the building blocks).
-
In production, a handler must not make a failure worse: log and return a miss on read failure, never throw.
The Pages Router: getStaticProps revalidate and res.revalidate()
ISR was introduced for the Pages Router in 9.5; the model is identical, the surface is different. A page exports
getStaticPaths (which paths to prerender and what to do with the rest) and getStaticProps (the data, and the
revalidate time).
// pages/legacy/[id].tsx
import type { GetStaticPaths, GetStaticProps } from 'next'
import { readStore } from '@/lib/store'
type Props = { title: string; priceCents: number; renderedAt: string }
export const getStaticPaths: GetStaticPaths = async () => ({
paths: [{ params: { id: '1' } }],
fallback: 'blocking', // other ids: wait for the HTML, then cache it
})
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const book = (await readStore())[String(params?.id)]
if (!book) return { notFound: true, revalidate: 5 }
return { props: { ...book, renderedAt: new Date().toISOString() }, revalidate: 5 }
}
export default function LegacyBook({ title, priceCents, renderedAt }: Props) {
return (
<main>
<h1>{title}</h1>
<p>Price: {(priceCents / 100).toFixed(2)} EUR</p>
<p>Rendered at {renderedAt}</p>
</main>
)
}
The build prints the lifetime in the Pages Router table, and the run shows the same cycle (the pages and the app directory happily coexist in one project):
Route (pages) Revalidate Expire
┌ ƒ /api/revalidate-legacy
└ /legacy/[id] 5s 1y
└ ● /legacy/1
14:21:14 200 STALE | Price: 9.99 EUR Rendered at 2026-10-03T14:21:07.985Z
14:21:14 200 HIT | Price: 10.99 EUR Rendered at 2026-10-03T14:21:14.070Z
14:21:14 200 MISS | Price: 7.99 EUR (/legacy/3: not in getStaticPaths, 'blocking' fallback)
14:21:14 200 HIT | Price: 7.99 EUR (/legacy/3 again)
14:21:14 404 | (/legacy/77: getStaticProps returned notFound)
getStaticProps returns props, redirect or notFound, plus an optional revalidate (seconds; the default false
means never). notFound: true yields a 404 even if the page existed before (a deleted post), and obeys the same
revalidate. context.revalidateReason tells the function why it runs: "build", "stale" or "on-demand".
The fallback values of getStaticPaths map onto what you already know:
fallback |
Pages Router behaviour | App Router equivalent |
|---|---|---|
|
Only the returned paths exist; anything else is a 404 |
|
|
An unknown path waits for the render, then is cached for later requests |
|
|
An unknown path serves a loading shell ( |
The App Shell with Cache Components (16.3) |
(fallback: true is not supported with output: 'export', and it does not update generated pages: ISR does.)
On-Demand: res.revalidate()
In the Pages Router the on-demand call lives in an API route, and unlike the App Router functions it is eager: it regenerates the page right now and returns when it is done.
// pages/api/revalidate-legacy.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
if (req.headers['x-revalidate-token'] !== process.env.REVALIDATE_TOKEN) {
return res.status(401).json({ message: 'Invalid token' })
}
const id = String(req.query.id ?? '')
if (!/^\d{1,10}$/.test(id)) return res.status(400).json({ message: 'Bad id' })
try {
// The real path, not a rewritten one.
await res.revalidate(`/legacy/${id}`)
return res.json({ revalidated: true })
} catch {
// The last successfully generated page keeps being served.
return res.status(500).send('Error revalidating')
}
}
The official guide passes the secret in the query string (?secret=…); this version takes it from a header, because
URLs end up in access logs. The run:
$ curl -X POST 'localhost:3503/api/revalidate-legacy?id=1' -H 'x-revalidate-token: wrong'
{"message":"Invalid token"} -> 401
$ node setprice.mjs 1 1199 # (before the call)
$ curl -X POST 'localhost:3503/api/revalidate-legacy?id=1' -H 'x-revalidate-token: <token>'
{"revalidated":true} -> 200
14:21:14 200 HIT | Price: 11.99 EUR Rendered at 2026-10-03T14:21:14.216Z # already the new copy: eager
If you rely on on-demand revalidation alone you do not need revalidate in getStaticProps: the default false waits
for res.revalidate(). Other Pages Router facts: on-demand ISR is stable since 12.2 (beta 12.1), the bot-aware ISR
fallback arrived in 12.0, and the Pages Router still uses the cacheHandler (singular) for on-demand revalidation
(x-prerender-revalidate), never cacheHandlers. The docs' own App Router note also points here: res.revalidate is
the only built-in way to regenerate eagerly; the App Router functions wait for the next request ("we’re working on
adding new methods to provide eager regeneration").
Which Tool for Which Change
On the Bookshelf: the book detail pages and the catalogue use cacheTag('books', 'book:<id>') with a
cacheLife of a few minutes as a safety net, the CMS webhook calls revalidateTag(…, 'max'), the admin "Publish"
Server Action calls updateTag, and a price shown in a cart or at checkout is never taken from an ISR page at all
(prices are re-read on the server when the order is created, see the security pages of this section).
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
|
Test with |
The first request after the lifetime still shows old data |
That is stale-while-revalidate working: the request that detects staleness is served stale. |
Expected; use |
A short |
Regeneration is request-triggered and the first visitor after each interval is served stale. |
Use a high value (an hour) plus on-demand revalidation; use dynamic rendering for real-time data. |
|
Segment config must be statically analysable. |
Write |
Build error |
|
Use |
|
A pattern needs the |
|
|
It targets one route; the other page shares the data through a tag. |
Also call |
|
Entries are keyed by the route file, not the visible URL. |
Pass the destination path ( |
|
Both exist only in Server Actions. |
Use |
|
Deprecated since 16: the profile is required. |
|
A tag does nothing |
The tag was never attached to the cached data, or it exceeds 256 characters (then it is never assigned). |
Attach with |
The CDN shows the old page after revalidation |
The CDN has its own copy until |
Purge the CDN after the Next.js call, HTML and RSC variants. |
Different visitors see different prices |
Several instances with per-instance caches. |
Shared cache handler plus tag coordination (Self-Hosting and Multiple Instances). |
A custom cache handler never invalidates a page |
Page entries carry their tags in the |
Read both (see the handler above). |
A failing CMS turns the page into an error |
It does not: a throw during regeneration keeps the last good page, logs the error and retries on later requests. |
Throw on a non-OK response (as |
|
It ran inside a |
Accept it (Next.js adds |
Build error |
|
Move it inside a |
Unlisted URLs are slow on Cache Components before 16.3 |
They wait for a full render. |
Upgrade and enable |
Rewritten or Proxy-dependent routes behave oddly on regeneration |
Proxy does not run for on-demand ISR requests. |
Revalidate the real path; do not make the page’s content depend on Proxy. |
A failing regeneration deserves its own measurement, because it is the property that makes ISR safe in front of a flaky
CMS. The CMS was made to answer 500 after the page had been cached, and getBook throws on a non-OK response:
# previous model
14:20:28 200 HIT | Price: 13.99 EUR Rendered at 2026-10-03T14:20:26.468Z
14:20:34 200 STALE | Price: 13.99 EUR Rendered at 2026-10-03T14:20:26.468Z # CMS down: regeneration threw
14:20:34 200 HIT | Price: 13.99 EUR Rendered at 2026-10-03T14:20:26.468Z # the last good page keeps being served
14:20:41 200 STALE | Price: 13.99 EUR Rendered at 2026-10-03T14:20:26.468Z # CMS back: next stale request regenerates
14:20:42 200 HIT | Price: 14.99 EUR Rendered at 2026-10-03T14:20:41.761Z
and the server log held Error: CMS answered 500. Cache Components behaved the same way (the stale 66.66 EUR copy was
served through two failed intervals, and 77.77 EUR appeared after the CMS recovered). Without it a thrown error
would have become the cache entry or a 500 for the visitor. Debugging helpers: logging.fetches.fullUrl in
next.config prints fetch cache hits and misses, and NEXT_PRIVATE_DEBUG_CACHE=1 in the environment of
next start logs every server cache read and write, for example
FileSystemCache: get /route-cache/APP_PAGE/<hash>/$/books/2 APP_PAGE true and the matching set (the key is opaque:
preserve it whole in a custom handler).
What Changed Recently, and Where the Books Are Outdated
| Version | Change for ISR |
|---|---|
9.5 (2020) |
Stable ISR ( |
12.0, 12.1, 12.2 |
Bot-aware ISR fallback (12.0); on-demand ISR with |
13.0, 13.4 |
App Router introduced (13.0) with |
14.1 |
Custom |
15 |
|
16.0 |
Cache Components ( |
16.2 |
Adapters API stable ( |
16.3 |
App Shell ISR for unknown params, with |
| The books say | Today |
|---|---|
ISR is |
That is the Pages Router (supported, legacy for new work). In the App Router it is the |
|
Still valid for the Pages Router. For the App Router use |
Pages are cached for the lifetime and then regenerated; no mention of a client router cache |
The browser keeps its own copy ( |
|
|
ISR is whatever the chosen host’s runtime does (the e-commerce book deploys to Netlify) |
Platform support is platform specific (adapters, stable in 16.2; OpenNext for non-Vercel hosts): check which of the cache, tags and CDN purge a platform implements before relying on it. |
A cached page is "static" and only a rebuild changes it |
With ISR and on-demand revalidation a build is needed only for code changes; with Cache Components even the first visit of an unlisted URL is instant. |
Rendering Fundamentals places ISR among CSR, SSR and SSG, Server-Side Rendering
is the per-request alternative, and the sibling pages on static site generation, Partial Prerendering and Cache
Components in this section cover the build-time half and the shell in more depth; the caching and data-fetching pages
that follow in the section cover 'use cache' and fetch in full. The React side of server rendering is in
React: Server Rendering, SSG and React Server Components, and the backend
counterpart of Route Handlers and webhooks is compared in
NestJS vs Next.js.