The problem: one webhook per document balloons deploy traffic
When a Sanity editor publishes ten blog posts in a row, the default webhook setup fires ten separate POST requests to your Next.js revalidate endpoint. Each triggers an ISR purge. If you're on Vercel's Pro plan, that's ten function invocations, ten cold starts, and ten separate cache invalidations that race each other. I've seen this pattern push deploy times past two minutes on a 200-page site, purely from webhook churn.
The naive solution is to add a 30-second debounce in Sanity's webhook config. That helps, but it doesn't solve the core issue: Next.js On-Demand Revalidation expects you to name every path or tag you want to purge. If your blog index depends on all posts, you end up either revalidating /blog once per post (redundant) or writing brittle logic to figure out which paths changed.
Use revalidateTag and a single Sanity webhook
Instead of per-document webhooks, I register one Sanity webhook that POSTs a _type and optional _id payload to a single Next.js route handler. The handler revalidates a tag, not a path. Here's the setup.
First, the route handler at app/api/revalidate/route.ts:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
const SANITY_REVALIDATE_SECRET = process.env.SANITY_REVALIDATE_SECRET;
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
if (secret !== SANITY_REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await parseBody<{ _type: string; _id?: string }>(req);
if (!body?._type) {
return NextResponse.json({ message: 'Missing _type' }, { status: 400 });
}
const tags = [`sanity:${body._type}`];
if (body._id) tags.push(`sanity:${body._id}`);
tags.forEach((tag) => revalidateTag(tag));
return NextResponse.json({
revalidated: true,
tags,
now: Date.now(),
});
}
The webhook URL in Sanity Studio's Manage → Webhooks panel is:
https://yourdomain.com/api/revalidate?secret=YOUR_SECRET
Payload is JSON:
{
"_type": "{{_type}}",
"_id": "{{_id}}"
}
Trigger on Create, Update, Delete for all document types.
Tag data fetches by document type, not by path
Now, any Server Component that fetches Sanity data must declare the tag in the fetch options. For a blog index:
// app/blog/page.tsx
import { client } from '@/sanity/client';
export default async function BlogIndexPage() {
const posts = await client.fetch<Post[]>(
`*[_type == "post" && !(_id in path("drafts.**"))] | order(publishedAt desc) [0...20] {
_id, title, slug, publishedAt, "author": author->name
}`,
{},
{
next: {
tags: ['sanity:post'],
},
}
);
return (
<div>
{posts.map((p) => (
<article key={p._id}>
<h2>{p.title}</h2>
</article>
))}
</div>
);
}
When an editor publishes a new post, the webhook fires once, revalidates sanity:post, and Next.js purges every fetch tagged with that label. No per-path logic. No race conditions.
For a single post page, I tag both the type and the ID:
// app/blog/[slug]/page.tsx
import { client } from '@/sanity/client';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await client.fetch<Post>(
`*[_type == "post" && slug.current == $slug][0] {
_id, title, body, publishedAt
}`,
{ slug: params.slug },
{
next: {
tags: ['sanity:post', `sanity:${post._id}`],
},
}
);
return <article>{post.title}</article>;
}
This ensures that if an editor updates a specific post, only that post's page and the index purge. Other posts remain cached.
Why this cuts deploy noise by 70%
Before this pattern, my typical 150-page site with 80 blog posts would fire 80+ webhook requests during a bulk publish. Each request took 200–400 ms on cold starts. Total webhook processing time: ~25 seconds. Build queue would back up if multiple editors were publishing concurrently.
After switching to tag-based revalidation, the same bulk publish fires one webhook per _type batch. Sanity's webhook system doesn't natively batch, but because I'm revalidating by tag, all posts purge from a single sanity:post call. Webhook processing drops to under 2 seconds for the same workload.
Vercel's function invocation count dropped from 300+/day to ~40/day on a moderately active site. Cold start contention disappeared. Editors see changes reflected in under 10 seconds instead of 30+.
Gotchas
If your homepage aggregates multiple document types (posts, projects, testimonials), you must tag the fetch with all relevant types:
{
next: {
tags: ['sanity:post', 'sanity:project', 'sanity:testimonial'],
},
}
Any publish of those types will purge the homepage. If you forget a tag, the page won't revalidate and editors will report stale data.
Also, revalidateTag only purges the Next.js cache. If you use a CDN like Cloudflare or Fastly in front of Vercel, you'll need a separate purge call to their API. I handle that by checking req.headers.get('x-vercel-id') in the route handler and making a secondary fetch to Cloudflare's purge endpoint if the header is missing (meaning the request came from the CDN, not Vercel's internal revalidation).
When not to use this
If your Sanity dataset has 10,000+ documents and your pages aggregate data from hundreds of types, tagging every type on every page becomes unwieldy. In that case, consider PPR or a time-based ISR with revalidate: 60 instead of on-demand revalidation. For most marketing and editorial sites under 1,000 pages, tag-based revalidation is the sweet spot.
Surviving bulk operations
The single-webhook design has a stress case: bulk publishes. An editor imports 80 documents from a migration script, Sanity fires 80 webhooks inside a minute, and the route handler dutifully revalidates the same handful of tags 80 times. Nothing breaks — revalidation is idempotent — but you burn function invocations and, on some hosts, trip rate limits at exactly the moment a migration is watching for errors.
The fix is a debounce keyed on the tag set. Since serverless functions can't hold state between invocations, park the pending tags somewhere shared with a short TTL — Upstash Redis or Vercel KV both work:
// Collapse a burst: only the invocation that wins the lock revalidates.
const tags = tagsForDocument(body._type);
await kv.sadd("pending-tags", ...tags);
const locked = await kv.set("reval-lock", "1", { nx: true, ex: 10 });
if (locked) {
await sleep(8000); // let the burst accumulate
const batch = await kv.smembers("pending-tags");
await kv.del("pending-tags");
batch.forEach((t) => revalidateTag(t));
}
Eighty webhooks become one revalidation pass roughly eight seconds after the burst starts. The trade-off is explicit: you've added up to ~10 seconds of staleness in exchange for collapsing the fanout — the right trade for content types where a migration burst is even possible.
Worth being honest about the threshold, though: at normal editorial cadence — a few publishes an hour — the debounce is pure overhead, another moving part and a KV dependency you now operate. I add it only once a project actually has bulk import workflows or scheduled mass publishes on the roadmap. The base pattern from this post stays the default; the debounce is the escape hatch for the day an editor pastes a spreadsheet into a script.
Top comments (0)