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.
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.
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.
// 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:
// 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/revalidateto/en/api/revalidatebreaks the POST, because redirects turn it into a GET. Exclude/apifrom the middleware matcher. - Reading the body twice. Signature verification needs the raw bytes. Call
req.text()once, verify, thenJSON.parse; callingreq.json()first loses the exact bytes that were signed. - Deleted entries have no content type. Contentful’s
DeletedEntrypayload 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
revalidateTagdoes 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.
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.