A client's editorial site runs Sanity as its CMS and Next.js with a one hour ISR window on the front end. The flow is the ordinary one and it had been working for months: an editor hits Publish in the Studio, Sanity fires a webhook at the revalidate route, and the affected pages refresh right away. Then one day an editor reported that a correction simply would not show up.
What made it interesting is that the complaint was only half true. The glossary listing page did update, within seconds of every publish. The per-term detail pages did not. They stayed stale for exactly as long as the one hour ISR window lasted, then quietly refreshed on their own as if nothing had ever gone wrong. And there were no errors anywhere. Sanity's webhook panel logged a successful delivery with a 200 response every single time, the revalidate route threw nothing, and the production logs were clean.
Separating out what was actually broken
With no error to read, I stopped staring at the code and started measuring the cache directly. Next reports its cache status in a response header, so two curl calls are enough to split the two big possibilities apart: either the webhook never lands, or it lands and one of the invalidation calls is failing to bite.
curl -sI https://client-site.example/glossary | grep -i x-nextjs-cache
# x-nextjs-cache: MISS
curl -sI https://client-site.example/glossary/the-term-i-just-edited | grep -i x-nextjs-cache
# x-nextjs-cache: HITThat carved up the problem space neatly. The listing path really was being invalidated, which means the webhook arrived, its auth passed, and the route ran all the way through. The only thing not biting was the call aimed at the per-slug path. So the bug was not in the transport. It was in one argument.
The slug that was not a string
The revalidate route looked roughly like this:
const body = await req.json();
revalidatePath("/");
revalidatePath("/glossary");
revalidatePath("/glossary/" + body.slug);
revalidateTag(body._type, "max");I logged the raw incoming payload, and the answer was sitting right there. body.slug was not a string:
{
"_id": "...",
"_rev": "...",
"_type": "glossaryTerm",
"title": "...",
"slug": { "_type": "slug", "current": "the-term-i-just-edited" }
}Sanity was sending the whole document, not the slim payload the route assumed. And the moment you concatenate an object onto a string with +, JavaScript falls back to its default string conversion. The result is [object Object]. So the call that actually executed on every single publish was this:
revalidatePath("/glossary/[object Object]");That path matches no route, and revalidatePath does not throw on a path it does not recognize. It simply does nothing. That is why the logs were spotless: from Next's point of view nothing was wrong, there was just an invalidation request for a page that happens not to exist.
The two literal calls, / and /glossary, kept working because neither of them touches body.slug at all. revalidateTag(body._type, "max") was safe too, since _type really is a plain string on the full document. The half-broken symptom was not a coincidence. It was an exact map of which arguments were poisoned and which were not.
What controls the payload shape
The shape of a webhook payload is decided by the Projection field in its config (in Sanity, under API then Webhooks then Edit). It is easy to overlook because it is allowed to be empty, and leaving it empty does not mean "just send some sensible default". It means this:
- Empty Projection: Sanity sends the ENTIRE document as it is, complete with
_id,_rev, and every other field. Slug fields come through in their native dataset shape, the object{ "_type": "slug", "current": "the-slug" }. - Projection set to
{"_type": _type, "slug": slug.current}: Sanity sends only those projected fields, andslugarrives as the flat string the route was written for.
This project had two webhooks, one for production and one for staging, and both had an empty Projection. Nobody had ever filled it in, going all the way back to day one.
The genuinely annoying part is that the code did not look wrong in any way. Directly above the handler sat a doc comment spelling out the correct payload shape:
/**
* Payload: { "_type": _type, "slug": slug.current }
*/The contract was written down clearly, the code underneath matched that contract, and no amount of code review was ever going to catch anything. The thing that never matched was the dashboard config, and the dashboard does not show up in a pull request.
The fix, on both sides
The first side is the webhook config itself. The Projection gets set to the minimal shape the route needs:
{"_type": _type, "slug": slug.current}
While I was in there I also filled in the Filter field, which was empty on both webhooks too. An empty Filter means the webhook fires on every change in the dataset, including drafts of internal-only document types that have no public page at all. Scoping it to the types that actually have pages is a lot calmer:
_type in ["article", "category", "glossaryTerm"]
The second side is the route itself, so that the next webhook someone sets up without a Projection cannot cause the same silent failure. A small helper that accepts both shapes:
function extractSlug(slug: unknown): string | undefined {
if (typeof slug === "string") return slug;
if (slug && typeof slug === "object" && "current" in slug) {
const current = (slug as { current?: unknown }).current;
if (typeof current === "string") return current;
}
return undefined;
}And its use in the handler:
const slug = extractSlug(body.slug);
if (slug) revalidatePath(`/glossary/${slug}`);Those two lines close another hole in the original code while they are at it. If the slug genuinely is missing from the payload, there is no longer a made up path being handed to revalidatePath. Anything without a slug is simply skipped.
Checklist
- If the webhook fires but only some pages refresh, measure before you read code. Run
curl -Iand check thex-nextjs-cacheheader. AMISSon the listing path plus aHITon the detail path means the transport is healthy and the damage is confined to one path argument. revalidatePathdoes not throw on an unrecognized path. It fails silently, so do not wait for an error to show up in the logs before you get suspicious.- In Sanity, an empty Projection means the full document, and on a full document a slug field is the object
{ _type, current }, not a string. - Always set the Projection to the minimal shape your route expects. For slug-driven content,
{"_type": _type, "slug": slug.current}is the safe default. - Set the Filter too, so the webhook does not fire for document types that have no page.
- A doc comment describing the payload shape guarantees nothing. The dashboard is what decides, and the dashboard is not reviewed alongside the code. When a route looks correct and still does not work, go and inspect the config that feeds it.