How to Use CMS Webhooks to Trigger Incremental Builds
TL;DR
CMS webhooks send an HTTP POST to your build system whenever content changes, triggering a rebuild of only the affected pages. In Sanity, you configure webhooks in the project settings with GROQ filters to fire only on relevant document types, then use on-demand revalidation in Next.js to update pages without a full site rebuild.
Key Takeaways
- Webhooks notify your build system of content changes so you can rebuild only affected pages.
- Sanity webhooks support GROQ filters — fire only when a specific document type or field changes.
- Use Next.js on-demand revalidation to update pages without a full rebuild.
- Webhook payloads include the document ID, type, and operation (create/update/delete).
- Without webhooks, you must either rebuild the entire site on a schedule or accept stale content.
CMS webhooks are HTTP callbacks that fire automatically when content changes in your headless CMS. Instead of polling for updates or rebuilding your entire site on a fixed schedule, webhooks let your build system react instantly and surgically — rebuilding only the pages that were actually affected by the change.
How Sanity Webhooks Work
When a document is created, updated, or deleted in Sanity Studio, Sanity's backend evaluates any configured webhooks. If the document matches the webhook's GROQ filter, Sanity sends an HTTP POST request to your specified endpoint. The payload contains the document ID, document type, and the operation that triggered the event.
Webhooks are configured in the Sanity management console at sanity.io/manage. Navigate to your project, select the API tab, and open the Webhooks section. Each webhook requires:
- A name to identify the webhook
- A URL — the endpoint that will receive the POST request
- A dataset (e.g. production)
- A GROQ filter to scope which documents trigger the webhook
- A secret for verifying the request signature
Configuring a Webhook with a GROQ Filter
GROQ filters are the most powerful feature of Sanity webhooks. They let you fire the webhook only when a document of a specific type changes, or even when a specific field on that document changes. This prevents unnecessary build triggers from unrelated content updates.
For example, to fire only when a published blog post changes, use this filter:
_type == "post" && !(_id in path("drafts.**"))The !(_id in path("drafts.**")) clause ensures the webhook only fires when a document is published, not when a draft is saved. This is critical — without it, every autosave in the Studio would trigger a build.
You can also scope to multiple document types:
(_type == "post" || _type == "author" || _type == "category") && !(_id in path("drafts.**"))The Webhook Payload
When Sanity fires a webhook, the POST body contains a JSON payload. The default payload includes the document ID, type, and the triggering operation. Here is a representative example:
{
"_id": "abc123",
"_type": "post",
"_rev": "xyz789",
"operation": "update",
"projectId": "your-project-id",
"dataset": "production"
}You can also configure a custom projection in the webhook settings to include additional fields from the document in the payload, which is useful when your revalidation logic needs to know the document's slug or other routing information.
Verifying the Webhook Signature
Every Sanity webhook can be configured with a secret. Sanity signs the request body using HMAC-SHA256 and includes the signature in the sanity-webhook-signature header. You must verify this signature in your endpoint before processing the payload — otherwise, anyone who discovers your endpoint URL could trigger spurious rebuilds.
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
const secret = process.env.SANITY_WEBHOOK_SECRET!
const isValid = await isValidSignature(body, signature, secret)
if (!isValid) {
return new Response('Invalid signature', { status: 401 })
}
const payload = JSON.parse(body)
// proceed with revalidation...
}On-Demand Revalidation in Next.js
Next.js App Router supports on-demand revalidation via revalidatePath() and revalidateTag(). This lets you invalidate the cache for specific pages or data tags without triggering a full site rebuild. Combined with Sanity webhooks, this is the most efficient way to keep a statically generated Next.js site up to date.
The general flow is:
- An editor publishes a change in Sanity Studio.
- Sanity evaluates the webhook filter and fires a POST to your Next.js API route.
- Your API route verifies the signature, extracts the document ID and type, and calls revalidatePath() or revalidateTag().
- Next.js purges the cached page and regenerates it on the next request.
- The visitor sees fresh content — typically within seconds of the publish action.
Full Revalidation API Route
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
import { NextRequest, NextResponse } from 'next/server'
const DOC_TYPE_TO_PATH: Record<string, string> = {
post: '/blog',
author: '/about',
category: '/blog',
}
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
const secret = process.env.SANITY_WEBHOOK_SECRET!
const isValid = await isValidSignature(body, signature, secret)
if (!isValid) {
return NextResponse.json({ message: 'Invalid signature' }, { status: 401 })
}
const { _type, _id, slug } = JSON.parse(body)
// Revalidate by tag (preferred — granular)
revalidateTag(`sanity:${_type}`)
revalidateTag(`sanity:${_id}`)
// Also revalidate the path if we know it
const path = DOC_TYPE_TO_PATH[_type]
if (path) revalidatePath(path)
if (slug?.current) revalidatePath(`/blog/${slug.current}`)
return NextResponse.json({ revalidated: true, type: _type, id: _id })
}Tagging Fetch Requests for Granular Invalidation
For revalidateTag() to work, your data-fetching calls must be tagged at the time of the request. In Next.js App Router, you add tags to the fetch options:
// lib/sanity.fetch.ts
import { client } from './sanity.client'
export async function getPost(slug: string) {
return client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug },
{
next: {
tags: [`sanity:post`, `sanity:post:${slug}`],
},
}
)
}
export async function getAllPosts() {
return client.fetch(
`*[_type == "post"] | order(publishedAt desc)`,
{},
{
next: {
tags: ['sanity:post'],
},
}
)
}When the webhook fires and calls revalidateTag('sanity:post'), Next.js invalidates every cached response that was tagged with that value — including both the post listing page and any individual post pages that fetched data with that tag.
Deploying the Webhook Endpoint
Your revalidation endpoint must be publicly accessible. When deploying to Vercel, the URL will be something like https://your-site.vercel.app/api/revalidate. Register this URL in the Sanity webhook configuration along with your secret. Store the secret in your deployment environment as SANITY_WEBHOOK_SECRET — never hardcode it.
During local development, use a tunneling tool such as ngrok or Cloudflare Tunnel to expose your local server and test the webhook end-to-end before deploying.
Suppose you are building a documentation site with Sanity as the CMS and Next.js App Router for rendering. Your content team publishes updates to doc pages frequently throughout the day, and you need those changes to appear on the live site within seconds — not hours.
Step 1: Configure the Webhook in Sanity
In sanity.io/manage, create a new webhook with the following settings:
- Name: Next.js On-Demand Revalidation
- URL: https://your-docs-site.vercel.app/api/revalidate
- Dataset: production
- Trigger on: Create, Update, Delete
- Filter: _type == "docPage" && !(_id in path("drafts.**"))
- Projection: {_id, _type, "slug": slug.current}
- Secret: a long random string stored in your Vercel environment
Step 2: Tag Your Data Fetches
In your Next.js app, tag every Sanity fetch with a consistent tag scheme so you can invalidate them precisely:
// lib/queries.ts
import { client } from './sanity.client'
export async function getDocPage(slug: string) {
return client.fetch(
`*[_type == "docPage" && slug.current == $slug][0] {
_id,
title,
slug,
body,
lastUpdated
}`,
{ slug },
{
next: {
// Tag with both the type and the specific document slug
tags: ['sanity:docPage', `sanity:docPage:${slug}`],
},
}
)
}
export async function getDocNavigation() {
return client.fetch(
`*[_type == "docPage"] | order(order asc) { _id, title, slug }`,
{},
{
next: {
// The nav must also be invalidated when any doc page changes
tags: ['sanity:docPage'],
},
}
)
}Step 3: Build the Revalidation Endpoint
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const rawBody = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
const valid = await isValidSignature(
rawBody,
signature,
process.env.SANITY_WEBHOOK_SECRET!
)
if (!valid) {
console.warn('Invalid webhook signature')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { _id, _type, slug } = JSON.parse(rawBody)
console.log(`Revalidating: type=${_type}, id=${_id}, slug=${slug}`)
// Invalidate all pages that fetched data tagged with this type
revalidateTag(`sanity:${_type}`)
// Invalidate the specific document's page
if (slug) {
revalidateTag(`sanity:${_type}:${slug}`)
revalidatePath(`/docs/${slug}`)
}
// Always revalidate the navigation (it lists all doc pages)
revalidatePath('/docs', 'layout')
return NextResponse.json({
revalidated: true,
timestamp: new Date().toISOString(),
})
}Step 4: Test the Full Flow
To test locally before deploying, run ngrok to expose your dev server:
# Start your Next.js dev server
npx next dev
# In a separate terminal, expose it via ngrok
ngrok http 3000
# ngrok will output a public URL like:
# https://abc123.ngrok.io
# Use this as your webhook URL in Sanity:
# https://abc123.ngrok.io/api/revalidatePublish a change to a docPage document in Sanity Studio, then watch your terminal. You should see the webhook arrive, the signature validate, and the revalidation tags fire — all within a second or two of clicking Publish.
Misconception: Webhooks trigger a full site rebuild
Many developers assume that a webhook automatically triggers a full Vercel or Netlify deployment. That is only true if you point the webhook URL at a deploy hook (e.g. https://api.vercel.com/v1/integrations/deploy/...). When you use on-demand revalidation instead, no new deployment occurs — Next.js simply purges and regenerates the affected cached responses in place. This is significantly faster and cheaper.
Misconception: You need a webhook for every document type
A single webhook with a broad GROQ filter can handle multiple document types. You do not need one webhook per type. Use a filter like (_type == "post" || _type == "author") and handle the routing logic inside your API route based on the _type field in the payload.
Misconception: Draft saves trigger the webhook
Without a GROQ filter, Sanity webhooks fire on every document mutation — including autosaves of drafts. This can flood your endpoint with dozens of requests per editing session. Always include !(_id in path("drafts.**")) in your filter to restrict the webhook to published documents only.
Misconception: Webhook delivery is guaranteed and ordered
Sanity webhooks are delivered at-least-once, not exactly-once. Your endpoint may receive duplicate deliveries, and in rare cases, deliveries may arrive out of order. Design your revalidation handler to be idempotent — calling revalidateTag() twice for the same tag is harmless, so this is rarely a practical problem, but you should be aware of it.
Misconception: You can skip signature verification in production
Skipping signature verification is a security vulnerability. Any actor who discovers your revalidation endpoint URL can send arbitrary POST requests to it, causing your site to continuously revalidate pages and potentially degrade performance. Always verify the HMAC signature using the @sanity/webhook package before processing any payload.