On-Demand ISR with revalidateTag and CMS Webhooks

This guide implements the webhook half of Next.js ISR Implementation: a route handler that verifies a CMS publish webhook, turns the entry into a precise set of cache tags, waits until the CMS delivery API actually returns the new version, and only then calls revalidateTag.

Time-based ISR makes editors wait. A five-minute window means a typo fix can take five minutes to appear, and on a quiet route it can take far longer, because regeneration only starts when a visitor arrives after the window. On-demand revalidation inverts that: the CMS tells Next.js exactly what changed, and the next request for any affected page regenerates it.

From publish event to invalidated tagsThe webhook handler verifies the signature, parses the entry, maps it to type, entry and dependency tags, confirms the new version is readable from the delivery API, and calls revalidateTag for each tag.POST /api/revalidateraw body + signatureVerify HMACtiming-safeMap entry to tagstype, entry, depsConfirm versiondelivery APIrevalidateTagfor each tag401log and dropvalidinvalidversion matches
Verification and version confirmation happen before any cache is touched, so a forged or early webhook changes nothing.

The Problem

Teams usually start with the one-liner from the Next.js docs: an API route that checks a ?secret= query parameter and calls revalidatePath('/blog/' + slug). It works in the demo and fails in three ways in production.

The first failure is path-based invalidation missing pages. An article appears on its own page, on the blog index, on two category pages, on its author’s page and in a “related posts” block on six other articles. Invalidating one path leaves the other eleven stale.

The second failure is a race with the CMS CDN. The webhook fires the moment the entry is published, but the CMS delivery API sits behind its own CDN. The regeneration that revalidatePath triggers can fetch the previous version, cache it and consider the page fresh. The editor sees nothing change and republishes, which is where “publish twice to make it work” folklore comes from.

The third failure is authentication. A shared secret in a query string ends up in CDN logs, analytics tools and browser history. Anyone who finds it can force regeneration of the whole site. Signature verification over the raw body closes that hole.

How Tag-Based Revalidation Works

In the App Router, every fetch can carry next: { tags: [...] }, and every unstable_cache or "use cache" function can declare tags. Next.js records which cached data entries and which rendered routes depend on each tag. revalidateTag("article:7Ht2") marks every entry carrying that tag as stale, and every route rendered from that data regenerates on its next request. Nothing is rendered during the webhook call, so it returns in milliseconds even when hundreds of pages are affected.

Tags should come from the data, not from routes. When the article page fetches an article and resolves its author and categories, it tags the fetch with article:<id>, author:<authorId> and category:<slug> for each category. A webhook for the author then invalidates every page that displayed that author’s bio, without the handler knowing which pages those are.

Webhook payload to tags, per CMSFor Contentful, Sanity and Strapi, where the entry type and id sit in the webhook payload and which tags the handler should invalidate.CMSType fromId fromTags to invalidateContentfulsys.contentType.sys.idsys.idtype, type:id, deps from referencesSanity_type (projection)_id without drafts.type, type:id, deps via GROQ projectionStrapi v4/v5modelentry.id or documentIdtype, type:idDirectuscollectionkeys[]type, type:id per key
Every handler emits the same three tag levels; only the payload paths differ between platforms.

Implementation

The handler below targets Contentful, whose webhooks send the entry in the body and can include a request-signing header. Swap the toChange function to support another CMS; the rest does not change.

TypeScript
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { createHmac, timingSafeEqual } from "node:crypto";

export const runtime = "nodejs";

interface ContentfulEntryWebhook {
  sys: { id: string; type: "Entry" | "DeletedEntry"; revision: number; contentType?: { sys: { id: string } } };
  fields?: Record<string, Record<string, unknown>>;
}

interface Change {
  type: string;
  id: string;
  revision: number;
  deleted: boolean;
}

function verify(raw: string, signature: string | null): boolean {
  if (!signature) return false;
  const expected = createHmac("sha256", process.env.REVALIDATE_SECRET ?? "").update(raw).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

function toChange(body: ContentfulEntryWebhook): Change {
  return {
    type: body.sys.contentType?.sys.id ?? "unknown",
    id: body.sys.id,
    revision: body.sys.revision,
    deleted: body.sys.type === "DeletedEntry",
  };
}

// The CMS CDN may still serve the previous revision for a few seconds.
async function waitForRevision(change: Change, attempts = 5): Promise<boolean> {
  if (change.deleted) return true;
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(
      `https://cdn.contentful.com/spaces/${process.env.CONTENTFUL_SPACE_ID}/entries/${change.id}`,
      { headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` }, cache: "no-store" },
    );
    if (res.ok) {
      const entry = (await res.json()) as { sys: { revision: number } };
      if (entry.sys.revision >= change.revision) return true;
    }
    await new Promise((r) => setTimeout(r, 400 * 2 ** i));
  }
  return false;
}

export async function POST(req: Request): Promise<Response> {
  const raw = await req.text();
  if (!verify(raw, req.headers.get("x-webhook-signature"))) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }
  const change = toChange(JSON.parse(raw) as ContentfulEntryWebhook);
  const ready = await waitForRevision(change);

  const tags = [change.type, `${change.type}:${change.id}`];
  for (const tag of tags) revalidateTag(tag);

  console.info(JSON.stringify({ event: "revalidate", ...change, tags, confirmed: ready }));
  // 200 even when unconfirmed: the tags are stale either way, and a retry
  // storm from the CMS would not make the CDN faster.
  return Response.json({ revalidated: true, tags, confirmed: ready });
}

The revision comparison is what fixes the CDN race. Contentful’s webhook body contains the entry’s sys.revision for the published version; the delivery API returns the same field. Polling until they match, with exponential backoff capped at about six seconds in total, makes the subsequent regeneration fetch the right version. Sanity exposes _rev for the same purpose, and Strapi’s updatedAt works as a monotonic version where no revision exists.

The data layer then tags every fetch consistently:

TypeScript
// lib/cms.ts
export async function getArticle(slug: string): Promise<Article | null> {
  const res = await fetch(`${process.env.CMS_API_URL}/articles?fields.slug=${encodeURIComponent(slug)}&include=2`, {
    headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
    next: { revalidate: 3600, tags: ["article"] },
  });
  const data = (await res.json()) as ArticleResponse;
  const article = data.items[0];
  if (!article) return null;
  // Tag with the entry and every referenced entry, discovered from the response.
  const deps = [`article:${article.sys.id}`, ...data.includes.Entry.map((e) => `${e.sys.contentType.sys.id}:${e.sys.id}`)];
  await tagResponse(deps);
  return normalizeArticle(article, data.includes);
}

tagResponse is a thin wrapper around unstable_cache or the cacheTag function available with the "use cache" directive in recent Next.js versions. Both attach tags after the fetch, which is necessary because reference ids are only known once the response is in hand.

Configuration Reference

Setting Value Notes
Webhook URL https://www.example.com/api/revalidate Use the canonical production host so preview deployments do not receive production webhooks.
Webhook triggers Entry publish, unpublish, delete; Asset publish Skip auto-save and draft events; they would invalidate for content readers cannot see.
Signature header x-webhook-signature Configure a custom header in the CMS with an HMAC of the body, or use the CMS’s native signing.
REVALIDATE_SECRET 32 random bytes, hex Rotate by accepting two secrets for a transition period.
Fallback revalidate 3600 s Only matters if a webhook is lost; on-demand handles normal freshness.
Version polling budget 5 attempts, 400 ms doubling About six seconds worst case; the CMS webhook timeout is usually 30 s.

Gotchas & Edge Cases

  • Middleware rewriting the webhook path. Locale middleware that redirects /api/revalidate to /en/api/revalidate breaks the POST, because redirects turn it into a GET. Exclude /api from the middleware matcher.
  • Reading the body twice. Signature verification needs the raw bytes. Call req.text() once, verify, then JSON.parse; calling req.json() first loses the exact bytes that were signed.
  • Deleted entries have no content type. Contentful’s DeletedEntry payload omits fields and some sys data. Store a small id-to-type map, or revalidate by id only, and let the type-level tag be refreshed by the fallback window.
  • Bulk publishes. Publishing a release of 300 entries sends 300 webhooks within seconds. Each call is cheap because revalidateTag does not render, but the regenerations that follow can hit CMS rate limits if traffic is high. Consider type-level invalidation for releases instead of per-entry tags.
  • Preview deployments. Every preview deployment has its own cache. Webhooks should target production only; previews use draft mode and do not need invalidation.

Verifying the Result

Publish a change, then request an affected page twice with curl -sI. The first response after the webhook may show x-nextjs-cache: STALE while regeneration runs; the second should show HIT with the new content. For a page that only references the entry (an author bio on an article page), repeat the check on that page to confirm the dependency tags worked. The x-nextjs-cache debugging guide explains each header value, and distributed CDN invalidation covers purging the layer in front of Next.js.

Publish latency with and without version confirmationTwo timelines after a publish; without confirmation the regeneration fetches the old revision and the page stays stale until the fallback window, with confirmation the page is fresh within about two seconds.No confirmation: stale pageold revision re-cachedConfirmation pollingConfirmed: fresh pagenew revision served0 s10 s20 s30 s40 s50 s60 spublish
Confirming the revision costs a second inside the webhook and saves an entire fallback window of staleness.

Frequently Asked Questions

Should I use revalidatePath or revalidateTag?

Use revalidateTag for content, because the same entry appears on many routes and tags follow the data. revalidatePath is still useful for pages whose content is not tied to a single entry, such as a layout change, or with revalidatePath("/", "layout") to invalidate everything after a schema migration.

Why return 200 when the version was never confirmed?

The tags are already invalidated, so a retry would only repeat the same work. Returning 5xx makes the CMS retry the webhook several times, which adds load without making its CDN faster. Log the unconfirmed case and let the fallback window cover it.

What if the webhook handler times out?

Most CMS platforms wait 10 to 30 seconds for a response and retry on timeout. Keep the handler well under that: the version polling budget above is about six seconds. If you need longer work, such as warming pages or purging a CDN, acknowledge the webhook first and continue in a queue or a background function.

Can one webhook handler serve several CMS platforms?

Yes. Route by path (/api/revalidate/contentful, /api/revalidate/sanity) and share the tagging and revalidation code. Each platform has a different payload shape and signing scheme, so keep a small adapter per platform that returns the common Change object.