D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Sanity Webhook Fires but Per-Slug Pages Stay Stale? An Empty Projection Turns It Into `revalidatePath('/glossary/[object Object]')`

· · 6 min read
Sanity Webhook Fires but Per-Slug Pages Stay Stale? An Empty Projection Turns It Into `revalidatePath('/glossary/[object Object]')`

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: HIT

That 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:

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