John Aleman

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:

  1. Time-based regeneration: cached content refreshes after a defined period.
  2. On-demand invalidation: content is invalidated immediately after a mutation.
  3. 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

APIPurposeBehavior
cacheLife()Time-based freshnessConfigures cache lifetime
cacheTag()Cache identificationAssociates entries with tags
revalidateTag(tag, "max")Stale-while-revalidateMarks matching data stale
updateTag(tag)Immediate expirationExpires tagged data for the next read
revalidatePath(path)Route-level invalidationInvalidates 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 webhook

The 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 form

Press 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.