A statically built site is fast and cheap, with one catch: content changes in the CMS don't appear until the site rebuilds. The fix takes ten minutes and costs nothing: a Sanity webhook that calls a Vercel deploy hook, but only when published content the site actually uses changes.
The pieces
- Vercel deploy hook. A secret URL that triggers a production build of a branch when it receives a POST. Project → Settings → Git → Deploy Hooks. Free on every plan.
- Sanity GROQ webhook. Fires on document changes that match a GROQ filter, and POSTs to a URL. Included in Sanity's free plan.
publish in Studio ──► Sanity webhook (filtered) ──► Vercel deploy hook ──► build ──► liveThe filter is the important part
A webhook that fires on everything rebuilds the site for every keystroke an editor makes in a draft, or for documents the site never renders. Two settings prevent that:
- Drafts off. Only published changes trigger it.
- A type filter listing exactly the document types the site renders:
_type in ["page", "post", "project", "testimonial", "legalPage", "siteSettings"]When the site starts rendering a new type, add it here. I learned this the hard way: my webhook only listed projects, testimonials and legal pages, so publishing the home page or site settings quietly did nothing.
Keep it in code
Clicking through the dashboard works, but a script makes the setup reviewable and repeatable, and lets you update the filter in place without touching the secret URL:
// scripts/sync-deploy-webhook.js — run with: sanity exec scripts/sync-deploy-webhook.js --with-user-token
import { getCliClient } from 'sanity/cli'
const TYPES = ['page', 'post', 'project', 'testimonial', 'legalPage', 'siteSettings']
const client = getCliClient({ apiVersion: '2025-01-03' })
const { projectId, dataset } = client.config()
const hooks = client.withConfig({ apiVersion: '2021-10-04', useProjectHostname: false })
const rule = {
on: ['create', 'update', 'delete'],
filter: `_type in ${JSON.stringify(TYPES)}`,
projection: '{_id, _type}',
}
const existing = (await hooks.request({ uri: `/hooks/projects/${projectId}` })).find(
(hook) => hook.dataset === dataset && new URL(hook.url).host === 'api.vercel.com',
)
if (existing) {
// Update the filter, keep the secret URL as it is.
await hooks.request({ uri: `/hooks/projects/${projectId}/${existing.id}`, method: 'PATCH', body: { rule, includeDrafts: false } })
} else {
const url = process.env.VERCEL_DEPLOY_HOOK_URL // from .env, never committed
await hooks.request({
uri: `/hooks/projects/${projectId}`,
method: 'POST',
body: { type: 'document', name: 'Vercel deploy on publish', url, httpMethod: 'POST', dataset, includeDrafts: false, rule },
})
}Treat the hook URL as a secret
Anyone holding a deploy hook URL can trigger builds. Keep it in .env (gitignored) and in the webhook's settings, nowhere else: not in the repo, not in CI logs, not in screenshots. If it leaks, delete the hook in Vercel and create a new one; the script above will pick up the new URL.
Things to know
- Builds queue up. Publishing five documents in a row starts several builds; Vercel runs them in order and the last one wins. For a personal site that's fine. For heavy editing, batch changes with a Sanity release or add a short debounce in a tiny serverless function between the two.
- Previews are separate. This is for production. Draft previews belong to visual editing (Sanity Presentation against a preview deployment), not rebuilds.
- Check it once. Publish a one-word change and watch a deployment appear in Vercel within a few seconds of hitting publish.