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-Control headers on those pages automatically.

  • Keep next build short 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).

sequenceDiagram autonumber participant V as Visitor participant S as Next.js server participant C as Server cache participant D as CMS or database V->>S: GET /books/1 (the cache entry is older than its revalidate time) S->>C: read the entry C-->>S: HTML and RSC payload, now stale S-->>V: 200 with the stale copy at once (x-nextjs-cache STALE) S--)D: background regeneration: fetch book 1 D-->>S: current data S->>C: write the new HTML and RSC payload together V->>S: GET /books/1 S->>C: read the entry C-->>S: fresh entry S-->>V: 200 with the new copy (x-nextjs-cache HIT)
Figure 1. A stale hit followed by background regeneration

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:

stateDiagram-v2 [*] --> Fresh: prerendered at build time or generated on first request Fresh --> Stale: revalidate time passes Stale --> Regenerating: next request (served the stale copy) Regenerating --> Fresh: new copy written Regenerating --> Stale: regeneration threw, last good copy kept Stale --> Expired: expire time passes with no request Expired --> Fresh: next request waits for a full render Fresh --> Stale: tag marked stale (revalidateTag with max) Fresh --> Expired: path or tag expired (revalidatePath, updateTag, expire 0)
Figure 2. The life of a cache entry

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

next build

Every route that generateStaticParams lists (and every static route) is rendered once and stored as seed HTML plus RSC payload. The route table prints the lifetimes in Revalidate and Expire columns.

The Pages Router has its own Route (pages) table with the same two columns.

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 proxy.ts do not apply to a regeneration.

Revalidate the real path (/books/1), not a rewritten one. See Redirects, Rewrites and Proxy.

next dev

Pages are always rendered on demand and never cached.

Verify ISR with next build and next start, never in dev.

Edge runtime, static export

Not supported. ISR needs the Node.js runtime; output: 'export' produces files only.

Platforms that run next start-compatible code (Node.js server, Docker) support it; adapters are platform specific.

Browser

The client router keeps its own copy for a while (the stale time under Cache Components, staleTimes otherwise).

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.

Test harness: a file standing in for the CMS (lib/store.ts and data/books.json)
// 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' ' ')"

Source: ISR and route.js

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.

The rules of revalidate

  • The value is false (the default: cache indefinitely, equivalent to Infinity), 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 = 600 works, revalidate = 60 * 10 does not. A build that contains the latter fails with Invalid segment configuration export detected, with nothing else in the log to point at it.

  • The lowest value across the layouts, the page and the fetch calls of one route wins, so a child page is regenerated at least as often as its parent layout. A fetch with next.revalidate lower than the route’s value lowers the route’s value.

  • A fetch with revalidate: 0 or cache: 'no-store' makes the route dynamic: it is rendered per request and ISR does not apply.

  • The segment value does not override the revalidate of an individual fetch; 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

/books/1 (listed)

200, HIT from the build-time seed

Prerendered at build time.

/books/3 (exists, not listed), first request

200, MISS: rendered on demand and stored. The second request is a HIT.

dynamicParams = true is the default: the page is generated on first visit, then behaves like a listed one.

/books/99 (does not exist)

404

notFound() runs before anything streams, so the status is real, and the 404 is itself cached for the lifetime.

/strict/2 with dynamicParams = false

404, while /strict/1 is 200

Anything that generateStaticParams does not return is a 404. This replaces fallback: false.

// 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).

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

stale

How long the client router may reuse the data without asking the server. Sent in the x-nextjs-stale-time header. A minimum of 30 seconds is enforced.

Nothing in the previous model (staleTimes is the global equivalent).

revalidate

After this time, a request is served the cached entry and triggers a background regeneration.

revalidate / next.revalidate.

expire

After this time without a request, the next request waits for a full render. Must be longer than revalidate.

The expire column of the route table, expireTime.

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

default

Standard content

5 minutes

15 minutes

never

seconds

Real-time data

30 seconds

1 second

1 minute

minutes

Frequently updated content

5 minutes

1 minute

1 hour

hours

Updated several times a day

5 minutes

1 hour

1 day

days

Updated daily

5 minutes

1 day

1 week

weeks

Updated weekly

5 minutes

1 week

30 days

max

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 an expire under 5 minutes: excluded from prerenders, resolved at request time. Of the presets only seconds falls under this.

  • stale under 30 seconds: excluded from prerenders (a prefetch would expire before the user could click).

  • stale between 30 seconds and 5 minutes: prerendered, but not part of the route’s App Shell.

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 generateStaticParams does 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

revalidateTag(tag, 'max')

Server Functions, Route Handlers

Every entry carrying the tag, across all pages

Served the stale entry while regeneration runs (stale-while-revalidate).

revalidateTag(tag, profile) or { expire: n }

Server Functions, Route Handlers

Same

Stale content may be served for at most expire seconds; { expire: 0 } never serves stale and blocks.

updateTag(tag)

Server Actions only

Same

Waits for fresh data: read-your-own-writes, no stale copy.

revalidatePath(path, type?)

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.

refresh()

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 /blog to /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 tag posts keeps its old copy until the tag is invalidated too. Mutations usually call both: revalidatePath('/blog') and updateTag('posts').

  • revalidatePath also invalidates the GET Route Handler at that path when it caches data (for example with fetch(…​, { 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.

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

s-maxage=31536000 (one year)

/shelf in the previous model.

ISR

s-maxage=<revalidate>, stale-while-revalidate=<expire minus revalidate>

s-maxage=5, stale-while-revalidate=31535995 (default expire one year).

Dynamic

private, no-cache, no-store, max-age=0, must-revalidate

The first visit of an unlisted route under Cache Components.

/_next/static/*

public, max-age=31536000, immutable

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-maxage and stale-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 the rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch and (for interception routes) next-url request headers; Next.js sends Vary for them and adds an _rsc query parameter that hashes them for CDNs that ignore Vary. The CDN must keep the rsc header and the _rsc parameter in its cache key.

  • It does not know about on-demand revalidation. revalidateTag and revalidatePath invalidate the Next.js server cache; the CDN keeps serving its own copy until s-maxage runs 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.ts should 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.

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 revalidateTag or revalidatePath invalidates 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

cacheHandler (singular; documented on the page "Custom Next.js Cache Handler")

The server cache: ISR pages, Route Handler responses, fetch data, optimised images

get, set, revalidateTag, resetRequestCache. Set cacheMaxMemorySize: 0 to disable the per-instance memory cache.

cacheHandlers (plural, default and remote keys)

'use cache' and 'use cache: remote' entries (Cache Components)

get(cacheKey, softTags), set(cacheKey, pendingEntry), refreshTags(), getExpiration(), updateTags()

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 generateBuildId a stable value (a git hash); when deploymentId is set, Next.js uses a constant build id and generateBuildId has 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

false

Only the returned paths exist; anything else is a 404

dynamicParams = false

'blocking'

An unknown path waits for the render, then is cached for later requests

dynamicParams = true (the default)

true

An unknown path serves a loading shell (router.isFallback) and the data arrives as JSON; crawlers get 'blocking'

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

flowchart TD A["The data behind a page changes"] --> B{"Do you know when?"} B -- "No, only roughly how often" --> C["Time-based: revalidate (previous model) or cacheLife (Cache Components)"] B -- "Yes, an event tells you" --> D{"Who needs to see the change?"} D -- "The user who just made it" --> E["updateTag in the Server Action (read your own writes)"] D -- "Everyone, a short stale window is fine" --> F["revalidateTag with the max profile"] D -- "Everyone, and stale is unacceptable" --> G["revalidateTag with expire 0, or revalidatePath"] A --> H{"Is the page tied to one path rather than data?"} H -- "Yes" --> I["revalidatePath, with type page or layout for patterns"] F --> J{"Is a CDN in front?"} G --> J I --> J J -- "Yes" --> K["Purge the CDN as well (HTML and RSC variants)"] A --> M{"More than one server instance?"} M -- "Yes" --> L["Share the cache and the tag state"]
Figure 3. Choosing how to refresh a static page

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

STALE never appears locally

next dev renders on demand and never caches.

Test with next build and next start.

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 updateTag or { expire: 0 } if the caller must see fresh data, or request twice in tests.

A short revalidate such as 1 does not make data "live"

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.

revalidate = 60 * 10 fails the build with Invalid segment configuration export detected

Segment config must be statically analysable.

Write 600.

Build error Route segment config "revalidate" is not compatible with nextConfig.cacheComponents

revalidate, dynamic and dynamicParams do not exist under Cache Components.

Use 'use cache' with cacheLife, and generateStaticParams with a Suspense boundary.

revalidatePath('/books/[id]') does nothing

A pattern needs the type argument; the server logs a warning.

revalidatePath('/books/[id]', 'page').

revalidatePath('/blog') leaves another page stale

It targets one route; the other page shares the data through a tag.

Also call updateTag('posts') or revalidateTag('posts', 'max').

revalidatePath('/blog') has no effect with a rewrite

Entries are keyed by the route file, not the visible URL.

Pass the destination path (/news).

updateTag or refresh throws in a Route Handler

Both exist only in Server Actions.

Use revalidateTag(tag, 'max') or revalidateTag(tag, { expire: 0 }).

revalidateTag(tag) with one argument is flagged by TypeScript

Deprecated since 16: the profile is required.

revalidateTag(tag, 'max').

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 next.tags or cacheTag; keep tags short.

The CDN shows the old page after revalidation

The CDN has its own copy until s-maxage ends.

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 x-next-cache-tags header of the value, not in ctx.tags.

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 getBook does) instead of caching an error body.

notFound() returns 200 under Cache Components

It ran inside a <Suspense> after the shell was sent.

Accept it (Next.js adds noindex), or resolve existence before the boundary (see Streaming and Suspense).

Build error Next.js encountered the unstable value new Date() while prerendering under Cache Components

Date or a random value ran in a prerendered component outside a cache.

Move it inside a 'use cache' function (cached on purpose), behind await connection(), or to the client (blocking-prerender-current-time).

Unlisted URLs are slow on Cache Components before 16.3

They wait for a full render.

Upgrade and enable partialPrefetching for App Shell ISR.

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 (revalidate in getStaticProps), the Pages Router model.

12.0, 12.1, 12.2

Bot-aware ISR fallback (12.0); on-demand ISR with res.revalidate() in beta (12.1) and stable (12.2).

13.0, 13.4

App Router introduced (13.0) with revalidate segment config, fetch options and revalidatePath/revalidateTag; App Router stable in 13.4.

14.1

Custom cacheHandler is stable.

15

fetch and GET Route Handlers are uncached by default; params and request APIs are async.

16.0

Cache Components (cacheComponents), 'use cache', cacheLife and cacheTag stable names; revalidateTag(tag, profile) (single argument deprecated), updateTag and refresh introduced; experimental.ppr and dynamicIO removed.

16.2

Adapters API stable (adapterPath), the way platforms integrate with Next.js builds.

16.3

App Shell ISR for unknown params, with partialPrefetching next to cacheComponents; the official ISR guide now exists once for the previous model and once for Cache Components.

The books say Today

ISR is revalidate in getStaticProps, with getStaticPaths and fallback

That is the Pages Router (supported, legacy for new work). In the App Router it is the revalidate segment config or cacheLife, with generateStaticParams and dynamicParams (or the App Shell under Cache Components).

res.revalidate() from an API route, with the secret in the query string

Still valid for the Pages Router. For the App Router use revalidateTag(tag, 'max') in a Route Handler with a signature or secret header; do not put secrets in URLs.

Pages are cached for the lifetime and then regenerated; no mention of a client router cache

The browser keeps its own copy (stale of the profile, staleTimes), and Server Action revalidation clears it.

Cache-Control: s-maxage=N, stale-while-revalidate set by hand in getServerSideProps to imitate ISR

getServerSideProps is Pages Router only. In the App Router ISR sets the header itself; use expireTime or cacheLife for the second number.

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.