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:
- Deploy, then load the post page twice so it is definitely cached.
- Change one word in the Studio and publish.
- 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.
- 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.