Skip to content

Webhook revalidation or a revalidate timer: pick one

A 60-second timer rebuilds pages nobody asked for and still makes editors wait. Tag every read, invalidate on publish, and stop guessing.

Sanity blog3 min readships at docs/solutions/blog-sanity/webhook-revalidation-vs-time-based.md

Tags: sanity · caching · revalidate · webhooks · isr

The first version of every headless-CMS integration looks like this:

export const revalidate = 60; // seconds

Sixty seconds feels like a reasonable compromise between "fresh" and "cheap". It is neither.

What the timer actually costs

Editors still wait, and they cannot tell how long. Publishing at 10:00:01 means the page changes at 10:01:00, unless nobody visits, in which case the revalidation is not even triggered, because ISR is request-driven. The editor refreshes, sees the old page, publishes again, and asks you why the CMS is broken.

You pay for rebuilds nobody wanted. Every page with a timer re-renders whenever it is visited after expiry, whether or not anything changed. On a blog with 200 posts and a crawler working through your sitemap, that is 200 renders and 200 GROQ queries an hour to reproduce byte-identical HTML.

Shortening it makes both worse. revalidate = 5 is a rendering loop with a CMS attached, and editors still see a delay.

The timer is a guess about when content changed. You do not have to guess: Sanity will tell you.

Tag every read

Caching by tag is what makes targeted invalidation possible. Every read in this repo goes through one wrapper that attaches them:

const post = await sanityFetch<PostDetail>({
  query: POST_QUERY,
  params: { slug },
  tags: ["post", `post:${slug}`],
});

Two tags, two granularities:

  • post: everything that shows a list of posts. The index, the home page teaser, the sitemap.
  • post:<slug>: this one document's page.

A page that reads three things ends up in three tag sets, and any of them can invalidate it independently.

Invalidate on publish

// src/app/api/sanity/revalidate/route.ts
export async function POST(request: Request) {
  if (!secretMatches(request.headers.get("x-webhook-secret"), expected)) {
    return Response.json({ revalidated: false }, { status: 401 });
  }

  const { _type, slug } = await request.json();
  const tags = [_type];
  if (typeof slug === "string" && slug !== "") tags.push(`${_type}:${slug}`);

  for (const tag of tags) revalidateTag(tag, "max");

  return Response.json({ revalidated: true, tags });
}

In sanity.io/manage, API -> Webhooks:

  • URL https://<your-site>/api/sanity/revalidate
  • Trigger on create, update, delete
  • Filter _type == "post"
  • Projection {"_type": _type, "slug": slug.current}
  • Header x-webhook-secret: <SANITY_REVALIDATE_SECRET>

The projection matters: without it Sanity posts the whole document, and you are parsing a body you do not need to extract two strings.

revalidateTag(tag, "max"): the second argument is not optional

The signature is revalidateTag(tag, profile). The single-argument form is deprecated and behaves like { expire: 0 }: the cached entry is dropped outright, so the next visitor blocks while the page re-renders.

"max" marks the entry stale instead. The next request is served the old page immediately and triggers a background re-render, and subsequent requests get the new one. Readers never wait; the editor's change is live within a request or two.

Use { expire: 0 } deliberately when correctness beats speed (a takedown, a GDPR deletion, a price that must never be shown again) and accept the blocking render that comes with it.

Verifying the loop

Do not trust it until you have watched it work:

  1. Deploy, then load the post page twice so it is definitely cached.
  2. Change one word in the Studio and publish.
  3. Reload. The first reload may show the old text (that is stale-while- revalidate doing its job) but the second must show the new one.
  4. Open the webhook's delivery log in sanity.io/manage. It shows the status code and the response body from your route, which is where {"revalidated": true, "tags": ["post", "post:my-slug"]} should be.

A 401 in that log means the header does not match the environment variable in the deployed environment. A 200 with revalidated: false means the filter or projection is sending you something other than what the route expects.

Do not do both

Once webhook invalidation works, remove the page-level revalidate exports. A timer on top of tag invalidation gives you rebuilds you did not ask for and makes "why did this page change?" unanswerable.

The one place a timer still earns its keep is content you do not control the publishing of: a third-party feed, a status page, an exchange rate. If nothing can call your webhook, a timer is the only tool you have. Everything in your own CMS should be event-driven.