Type something to search...
Triggering Site Rebuilds With Sanity Webhooks

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:

ApproachHow it worksTime to liveBest for
Full rebuildWebhook calls a deploy hook, the host rebuilds the whole siteBuild time, from seconds to minutesStatic Astro, SvelteKit or Next.js output: 'export' sites
On-demand revalidationWebhook calls your endpoint, which invalidates cached pages or tagsA few secondsNext.js and hosts with cache-tag purging
Live updatesPages subscribe to changes through the Live Content APINear-instantNext.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 like https://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:

  1. URL: the deploy hook URL.
  2. Dataset: production. A staging dataset should have its own webhook pointing at a staging build, if any.
  3. Trigger on: create, update and delete.
  4. Filter: the document types that appear on the site, such as _type in ["post", "page", "author", "category", "siteSettings"].
  5. Projection: leave it small, for example {_id, _type}. The deploy hook ignores the body anyway.
  6. 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_SECRET in 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-Control headers, and purge from a webhook. On Netlify, add a Netlify-Cache-Tag header 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

SymptomLikely causeFix
No build after publishingFilter doesn't match, or wrong datasetTest the filter in Vision; check the attempts log
Build runs but shows old contentBuild fetched from the CDN before it updatedFetch with useCdn: false at build time
Revalidated page still stalePage refetched before the change was queryablePass true as the third argument to parseBody
Old URL still live after a slug changeOnly the new slug was revalidatedSend before().slug.current and revalidate both
Builds on every keystrokeDrafts enabled on the webhookDisable drafts for rebuild webhooks
401 from your endpointSecret mismatch or body parsed too earlyCopy 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper