
Triggering Site Rebuilds With Sanity Webhooks
To trigger site rebuilds with Sanity webhooks, create a deploy hook on your host (Vercel, Netlify, Cloudflare Pages and others all offer one), then add a Sanity webhook that sends a request to that URL whenever a published document changes. For sites that support on-demand revalidation, such as Next.js, point the webhook at a Route Handler instead, verify it with parseBody from next-sanity/webhook and call revalidateTag(), so only the affected pages refresh in seconds without a full build.
Static and cached sites are fast because they don't ask Sanity for content on every request. The trade-off is that they need to be told when content changes. This guide covers choosing between full rebuilds and on-demand revalidation, wiring deploy hooks on Vercel, Netlify and Cloudflare, revalidating tags and paths in Next.js, handling Astro sites, filtering out changes that don't matter, and debouncing bursts of publishes.
Rebuild or Revalidate?
There are two ways to get new content onto a pre-rendered site:
| Approach | How it works | Time to live | Best for |
|---|---|---|---|
| Full rebuild | Webhook calls a deploy hook, the host rebuilds the whole site | Build time, from seconds to minutes | Static Astro, SvelteKit or Next.js output: 'export' sites |
| On-demand revalidation | Webhook calls your endpoint, which invalidates cached pages or tags | A few seconds | Next.js and hosts with cache-tag purging |
| Live updates | Pages subscribe to changes through the Live Content API | Near-instant | Next.js apps using defineLive |
Full rebuilds are simple and work with any static site generator, but build time grows with the site, and every publish rebuilds everything. Revalidation scales better because a change to one post only refreshes the pages that use it. If you use defineLive from next-sanity, you may not need webhooks for freshness at all. Improving site performance with Sanity caching compares those options in depth.
Whichever you choose, the general webhook settings, such as filters, projections and secrets, are explained in how to use Sanity webhooks. This post focuses on what happens at the other end.
Option 1: Trigger a Deploy Hook
A deploy hook is a secret URL that starts a new build when it receives a request. You don't write any code; Sanity calls the host directly.
Create the Hook on Your Host
- Vercel: Project Settings > Git > Deploy Hooks. Name it, choose the branch (usually
main) and copy the URL, which looks likehttps://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy. - Netlify: Site configuration > Build & deploy > Continuous deployment > Build hooks. The URL looks like
https://api.netlify.com/build_hooks/xxx. - Cloudflare Pages: Settings > Builds > Deploy hooks. The URL starts with
https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/.
All three accept a POST with an empty body. Treat the URL as a secret: anyone who has it can start builds on your account.
Create the Sanity Webhook
In sanity.io/manage, go to API > Webhooks and create a webhook with:
- URL: the deploy hook URL.
- Dataset:
production. A staging dataset should have its own webhook pointing at a staging build, if any. - Trigger on: create, update and delete.
- Filter: the document types that appear on the site, such as
_type in ["post", "page", "author", "category", "siteSettings"]. - Projection: leave it small, for example
{_id, _type}. The deploy hook ignores the body anyway. - HTTP method: POST.
Leave drafts disabled, so builds only start when something is published. Netlify lets you add a trigger_title query parameter to the hook URL, which shows in the deploy log and makes it obvious that a build came from Sanity:
https://api.netlify.com/build_hooks/xxx?trigger_title=Sanity+content+update
Deploy hooks can't verify Sanity's signature, so the secret URL is the only protection. If that's not enough, put your own endpoint in between, as shown in the debouncing section below.
Option 2: On-Demand Revalidation in Next.js
On-demand revalidation clears specific cached data instead of rebuilding the site. It needs two pieces: fetches tagged with cache tags, and a Route Handler that revalidates those tags when Sanity calls it.
Tag Your Fetches
Tag each fetch with the document types it depends on, and optionally a more specific tag for one document:
// src/sanity/lib/fetch.ts
import { client } from './client'
export async function sanityFetch<T>({
query,
params = {},
tags,
}: {
query: string
params?: Record<string, unknown>
tags: string[]
}): Promise<T> {
return client.fetch<T>(query, params, { next: { tags } })
}
// In a post page
const post = await sanityFetch({
query: POST_QUERY,
params: { slug },
tags: ['post', `post:${slug}`, 'author'],
})
The next-sanity guide covers setting up the client these helpers use.
Write the Revalidation Handler
parseBody from next-sanity/webhook reads the raw body, verifies the sanity-webhook-signature header against your secret and returns the parsed payload. Its third argument waits briefly before returning, so that the Content Lake and API CDN have the new content before your pages refetch:
// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
type WebhookPayload = {
_type: string
slug?: string | null
previousSlug?: string | null
}
export async function POST(req: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) return new Response('Missing secret', { status: 500 })
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(req, secret, true)
if (!isValidSignature) return new Response('Invalid signature', { status: 401 })
if (!body?._type) return new Response('Bad request', { status: 400 })
const tags = new Set([body._type])
if (body.slug) tags.add(`${body._type}:${body.slug}`)
if (body.previousSlug) tags.add(`${body._type}:${body.previousSlug}`)
for (const tag of tags) revalidateTag(tag, 'max')
return NextResponse.json({ revalidated: [...tags] })
} catch (error) {
console.error(error)
return new Response('Error', { status: 500 })
}
}
In Next.js 16, revalidateTag() takes a cache profile as its second argument. 'max' marks the tag as stale, so the next visitor gets the cached page immediately while a fresh one is generated in the background.
Then create the webhook in Manage:
- URL:
https://www.example.com/api/revalidate - Trigger on: create, update and delete
- Filter:
_type in ["post", "page", "author", "category"] - Projection:
{_type, "slug": coalesce(slug.current, before().slug.current), "previousSlug": before().slug.current} - Secret: a long random string, also set as
SANITY_REVALIDATE_SECRETin your hosting environment
The before() values make sure that renaming or deleting a post also clears the page at its old URL.
Revalidate Paths Instead of Tags
If you don't use tags, revalidatePath() refreshes a route. Map the document type to its URL:
import { revalidatePath } from 'next/cache'
const paths: Record<string, (slug?: string | null) => string[]> = {
post: (slug) => ['/blog', ...(slug ? [`/blog/${slug}`] : [])],
page: (slug) => (slug ? [`/${slug}`] : []),
siteSettings: () => ['/'],
}
for (const path of paths[body._type]?.(body.slug) ?? []) {
revalidatePath(path)
}
Use revalidatePath('/', 'layout') for changes that affect every page, such as navigation or site settings. Tags are usually more precise, because a post can appear on many pages, but paths are easier to reason about on small sites. Revalidate a sitemap tag here too if your dynamic sitemap is cached.
Revalidate Pages That Show Referenced Content
A post page that shows its author's name depends on the author document. Tagging the post fetch with 'author', as above, means publishing any author refreshes every post page. That's simple and correct. On large sites where that's too broad, tag by ID instead (author:${authorId}) and project the ID in the webhook:
{ _type, _id, "slug": slug.current }
Rebuilding Astro Sites
How you refresh an Astro site depends on its output mode:
- Static output (the default): use a deploy hook, exactly as in Option 1. Every page is regenerated at build time with the latest content.
- Server output with a CDN cache: render on request, cache at the edge with
Cache-Controlheaders, and purge from a webhook. On Netlify, add aNetlify-Cache-Tagheader to responses and purge by tag:
// netlify/functions/sanity-purge.mts
import { purgeCache } from '@netlify/functions'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
export default async (req: Request) => {
const rawBody = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
const secret = process.env.SANITY_WEBHOOK_SECRET!
if (!(await isValidSignature(rawBody, signature, secret))) {
return new Response('Invalid signature', { status: 401 })
}
const { _type } = JSON.parse(rawBody) as { _type: string }
await purgeCache({ tags: [_type] })
return new Response('Purged', { status: 202 })
}
---
// src/pages/blog/[slug].astro (server-rendered)
Astro.response.headers.set('Cache-Control', 'public, max-age=0, must-revalidate')
Astro.response.headers.set('Netlify-CDN-Cache-Control', 'public, durable, s-maxage=31536000')
Astro.response.headers.set('Netlify-Cache-Tag', 'post')
---
For most content sites, static output with a deploy hook is the simpler choice. Building a static site with Astro and Sanity walks through that setup.
Only Rebuild When It Matters
Every unnecessary build costs build minutes and delays the next real one. Use the webhook filter to skip changes that don't affect the site:
_type in ["post", "page", "author", "category", "siteSettings"]
&& (
delta::operation() != "update"
|| delta::changedAny((title, slug, body, excerpt, mainImage, author, categories, seo))
)
delta::changedAny() matches only when one of the listed fields changed, so an internal note or a workflow status field won't start a build. The delta::operation() check makes sure creates and deletes always fire. Keep the field list in step with what your front end renders.
Types that never appear on the site, such as editorial notes or form submissions, should simply be left out of the _type list.
Debounce Bursts of Publishes
Editors often publish several documents in a row, or a release publishes dozens at once. With a direct deploy hook, each publish can queue its own build. Vercel and Netlify both queue builds rather than run them in parallel, so you end up with several builds in a row, all producing nearly the same site.
You can collapse a burst into one build by putting a small endpoint between Sanity and the deploy hook. The webhook marks the site as dirty, and a scheduled job triggers one build if anything changed:
// src/app/api/content-changed/route.ts
import { parseBody } from 'next-sanity/webhook'
import { kv } from '@/lib/kv' // any key-value store, such as Redis or Vercel KV
export async function POST(req: Request) {
const { isValidSignature } = await parseBody(req, process.env.SANITY_WEBHOOK_SECRET!, false)
if (!isValidSignature) return new Response('Invalid signature', { status: 401 })
await kv.set('content-dirty', Date.now())
return new Response('Queued', { status: 202 })
}
// src/app/api/cron/rebuild/route.ts, called every 5 minutes by a cron job
import { kv } from '@/lib/kv'
export async function GET(req: Request) {
if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorised', { status: 401 })
}
const dirtySince = await kv.get<number>('content-dirty')
if (!dirtySince) return Response.json({ rebuilt: false })
// Wait for a quiet minute so a burst of publishes finishes first
if (Date.now() - dirtySince < 60_000) return Response.json({ rebuilt: false, waiting: true })
await kv.del('content-dirty')
await fetch(process.env.DEPLOY_HOOK_URL!, { method: 'POST' })
return Response.json({ rebuilt: true })
}
Each new publish updates the timestamp, so the build waits until editors have been quiet for a minute. Vercel Cron Jobs, Netlify Scheduled Functions, Cloudflare Cron Triggers or a GitHub Actions schedule can all call the cron route. The extra benefit is that this endpoint verifies Sanity's signature, so the deploy hook URL never leaves your server.
Troubleshooting Rebuilds
| Symptom | Likely cause | Fix |
|---|---|---|
| No build after publishing | Filter doesn't match, or wrong dataset | Test the filter in Vision; check the attempts log |
| Build runs but shows old content | Build fetched from the CDN before it updated | Fetch with useCdn: false at build time |
| Revalidated page still stale | Page refetched before the change was queryable | Pass true as the third argument to parseBody |
| Old URL still live after a slug change | Only the new slug was revalidated | Send before().slug.current and revalidate both |
| Builds on every keystroke | Drafts enabled on the webhook | Disable drafts for rebuild webhooks |
| 401 from your endpoint | Secret mismatch or body parsed too early | Copy the secret again; verify the raw body |
The CDN timing issue is explained in Sanity CDN vs live API. For a manual fallback, a deploy button in the Studio lets editors rebuild on demand, as shown in Sanity dashboard widgets.
FAQ: Rebuilding Sites With Sanity Webhooks
Create a deploy hook on your host, then add a Sanity webhook in Manage that sends a POST to that URL on create, update and delete for the document types your site uses. Leave drafts disabled so only published changes trigger builds.
Usually not. Tag your Sanity fetches and revalidate those tags from a webhook handler with revalidateTag, or use defineLive from next-sanity. Only the affected pages refresh, in seconds, without a new deployment.
Not directly, because the host receives the request, not your code. The deploy hook URL itself is the secret. If you need verification, send the webhook to your own endpoint, check it with @sanity/webhook or parseBody, and call the deploy hook from there.
The page probably refetched from the API CDN before it had the new version. Pass true as the third argument to parseBody so it waits briefly, or fetch with useCdn set to false in revalidated code paths.
Send the webhook to your own endpoint that records a change, and let a scheduled job trigger a single build once publishing has been quiet for a short time. Tight filters with delta::changedAny also cut the number of builds.
Conclusion
Keeping a fast site fresh comes down to telling it when content changes. For static sites, that's a deploy hook and a Sanity webhook with a sensible filter, set up in a few minutes without code. For Next.js, on-demand revalidation is the better tool: tag your fetches, verify the webhook with parseBody, and call revalidateTag() so only affected pages refresh.
Whichever route you take, send just the data your handler needs, include before() values so renamed and deleted pages are cleared, and use delta::changedAny() to ignore edits that don't change the site. When bursts of publishes become a problem, put a small debouncing endpoint in front of your deploy hook. If your site also writes content back to Sanity, how to use Sanity's Mutation API covers that side.


