How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler

May 09, 2026 · 5 min read

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.

Related posts

All posts →