On-Demand and Incremental Content Regeneration
Time-based regeneration as a safety net, precise tag invalidation after editorial writes, and protected webhooks for changes that originate outside the application.
View the live demo →Concept: two clocks
Fresh enough by time, fresh now by event
Published content wants two things that pull in opposite directions: cache it aggressively so every reader gets a fast response, but stop serving the old version as soon as an editor changes it. Time-based regeneration and on-demand invalidation solve different halves of that problem.
cacheLife() is the safety net. After its revalidation interval, the next request may receive the cached result while Next.js rebuilds it in the background. An editorial event is the fast path: invalidate the affected tag immediately after the source of truth changes.
The goal is to serve cached content quickly while keeping it fresh without rebuilding the entire application. In Next.js 16 that means combining three strategies:
- Time-based regeneration: cached content refreshes after a defined period.
- On-demand invalidation: content is invalidated immediately after a mutation.
- Webhook-driven invalidation: an external system, such as a CMS, triggers the cache update.
read → "use cache" + cacheLife() + cacheTag() (cached, time-bounded)
edit → Server Action → updateTag() (editor sees it now)
CMS → webhook → Route Handler → revalidateTag() (external change)The timer catches missed events. The event keeps readers from waiting for the timer.
Concept: time-based regeneration
Give every cached query an explicit fallback policy
In the Cache Components model, caching is opt-in. The field-notes listing uses 'use cache', an explicit five-minute revalidation interval, and a collection tag:
export async function getPublishedFieldNotes() {
"use cache";
cacheLife({
stale: 300,
revalidate: 300,
expire: 86_400,
});
cacheTag("field-notes");
return queryPublishedFieldNotes();
}revalidate starts stale-while-revalidate after five minutes. expire is the hard edge: after a day without a usable entry, the next request waits for fresh data instead of receiving an arbitrarily old result.
cacheLife("hours") and the other named profiles bundle their own stale, revalidate, and expire values, so a profile is not simply a fixed hourly rebuild. And none of these settings is a timer that proactively rebuilds every entry: they only decide what the next request is allowed to receive, so an article nobody visits is never regenerated.
Concept: tag scope
Invalidate the data relationship, not every URL
A tag follows data wherever it appears. The listing carries the collection tag; each article carries both the collection tag and a slug-specific tag. That lets one event refresh every surface using the archive, while a targeted edit can name one article.
export async function getFieldNoteBySlug(slug: string) {
"use cache";
cacheLife({ stale: 300, revalidate: 300, expire: 86_400 });
cacheTag("field-notes", fieldNoteTag(slug));
return queryFieldNoteBySlug(slug);
}revalidatePath() works at the route level. It is useful when rendered output itself must be refreshed, but it does not invalidate the same tagged data on other routes. Tags are normally the more precise primary tool; paths are the broader route fallback.
Plan for the places one piece of data surfaces. If an article also appears in a listing, invalidate the listing's tag too (the demo does, via field-notes). If a slug changes, invalidate both the old and the new slug tags.
Concept: writes
Match invalidation semantics to who made the change
When a user publishes through a Server Action, they expect the next render to show their write. Next.js recommends updateTag() for that read-your-own-writes path. External systems call Route Handlers instead, where revalidateTag() is available:
// Server Action: expire now for the editor's next read.
const { note } = await publishNextFieldNote();
updateTag("field-notes");
updateTag(fieldNoteTag(note.slug));
revalidatePath("/patterns/content-revalidation/field-notes");
// Webhook: the external CMS already performed the write.
revalidateTag("field-notes", { expire: 0 });
revalidateTag(fieldNoteTag(slug), { expire: 0 });The one-argument revalidateTag(tag) form in older examples is deprecated. Use "max" when stale-while-revalidate is acceptable, or { expire: 0 } when an external event requires the old entry to be gone before the next read.
Concept: webhook security
A cache purge endpoint is an administrative endpoint
A public revalidation URL lets anyone manufacture regeneration work. Under traffic, that becomes a denial-of-service lever against the cache and the data source behind it. The demo requires a secret in the x-revalidation-secret header, compares it in constant time, rejects short or missing configuration, and accepts only the expected payload shape. A shared secret is the illustrative floor: production integrations should use the CMS's documented signature verification over the raw request body, validate event types and identifiers, and protect against replay attacks.
Authenticate first, validate the event second, invalidate last. Never put the webhook secret in a public environment variable or a browser form.
Concept: choosing the API
Comparing the regeneration APIs
| API | Purpose | Behavior |
|---|---|---|
| cacheLife() | Time-based freshness | Configures cache lifetime |
| cacheTag() | Cache identification | Associates entries with tags |
| revalidateTag(tag, "max") | Stale-while-revalidate | Marks matching data stale |
| updateTag(tag) | Immediate expiration | Expires tagged data for the next read |
| revalidatePath(path) | Route-level invalidation | Invalidates cached data associated with a path |
updateTag() is restricted to Server Actions. revalidateTag() works from Server Actions and Route Handlers.
Concept: where it lives
Keep caching policy beside the query, not the page
features/articles/
├── article.service.ts ← business rules
├── article.repository.ts ← persistence
├── article.queries.ts ← "use cache" + cacheLife + cacheTag
└── article.actions.ts ← "use server" + updateTag
app/
├── articles/[slug]/page.tsx ← calls getArticle(slug)
└── api/revalidate/route.ts ← authenticated webhookThe page just calls a query; the lifetime and tags are declared once, beside the query that owns them. The demo keeps the same split in miniature inside data.ts, actions.ts, and the revalidate Route Handler.
ISR and caching are related, not identical. Traditional ISR uses route-level revalidate settings. Cache Components adds explicit caching with "use cache", cacheLife(), and cacheTag() for finer control, and exact behavior depends on whether Cache Components is enabled.
For editorial content, a good default is three layers: a time-based lifetime as a safety net, updateTag() after internal writes, and authenticated revalidateTag() for external updates. Fresh content, without invalidating unrelated content.
Implementation example
The Field Notes demo's structure
The source's Prisma blog becomes an editorial field journal backed by an in-process graphql-yoga schema. The storage is intentionally disposable; the cache boundaries and invalidation flow are the real subject of the example.
app/patterns/content-revalidation/
├── data.ts ← GraphQL reads/mutation + cached wrappers
├── api/revalidate/route.ts ← protected external webhook
└── field-notes/
├── layout.tsx ← static publication chrome
├── page.tsx ← tagged listing + publishing control
├── actions.ts ← mutation + updateTag + revalidatePath
└── [slug]/page.tsx ← individually tagged article route
components/patterns/content-revalidation/
├── body.tsx ← this guide
├── field-note-list.tsx ← cached archive presentation
└── publication-controls.tsx ← no-JavaScript Server Action formPress Publish next dispatch in the live demo. The GraphQL mutation changes the mock CMS, the action expires the collection and article tags, and the listing re-renders with the new dispatch without waiting for its five-minute safety window.
Open a dispatch afterward and its slug-specific cache entry is ready to be invalidated independently. In a real deployment, the CMS would call the protected Route Handler after its own publish transaction.