Revalidate Next.js pages with Sanity webhooks
Set up webhook-based cache revalidation in Next.js using parseBody from next-sanity/webhook. Covers path-based and tag-based revalidation for App Router and Pages Router.
When content changes in Sanity, you can use webhooks to revalidate cached pages in your Next.js application on demand. The next-sanity toolkit includes parseBody, a utility that validates webhook signatures and parses payloads, so you can build secure revalidation handlers with minimal code.
This guide covers two revalidation patterns:
- Path-based revalidation: revalidate specific routes when a document changes
- Tag-based revalidation: revalidate all pages that depend on a document type
Both patterns work with App Router route handlers. Pages Router API routes support path-based revalidation, and the Pages Router section explains how to adapt tag-based patterns.
Before you start: this guide covers the mechanism for webhook-based revalidation. If you're deciding which caching strategy to use (time-based, tag-based, or path-based), see Caching and revalidation in Next.js.
Prerequisites
- A Next.js application with
next-sanityconfigured. - Next.js 16 or later and
next-sanity12 or later. The tag-based examples use the two-argumentrevalidateTagcall that Next.js 16 requires. - A Sanity project with permission to create webhooks.
- A shared secret for webhook signature validation.
Set up the shared secret
Create a SANITY_REVALIDATE_SECRET environment variable with a random string. This secret must match in both your Sanity webhook configuration and your Next.js application.
# .env.local
SANITY_REVALIDATE_SECRET=YOUR_RANDOM_SECRETAdd the same value to your hosting provider's environment variables (for example, Vercel or Netlify).
The parseBody utility
parseBody from next-sanity/webhook handles signature validation and payload parsing in one call:
import { parseBody } from 'next-sanity/webhook'
const { isValidSignature, body } = await parseBody<MyPayloadType>(
request,
secret,
waitForContentLakeEventualConsistency
)The function accepts three arguments:
| Argument | Type | Description |
|---|---|---|
| request | NextRequest | The incoming webhook request |
| secret | string (optional) | Your SANITY_REVALIDATE_SECRET value. If you omit it, parseBody skips signature validation and returns isValidSignature as null. |
| waitForContentLakeEventualConsistency | boolean (optional) | When true, adds a short delay before returning. This gives the Content Lake time to propagate changes, preventing your revalidation from fetching stale data. Defaults to true. |
parseBody returns an object with:
isValidSignature:trueif the webhook request was signed with the correct secret,falseif it wasn't, ornullif the request has no signature header or you didn't pass a secretbody: the parsed webhook payload, typed as the generic you provide, ornullif the request has no signature header or an empty body
By default, parseBody waits 3 seconds before returning so the Content Lake has time to reach eventual consistency and the pages you revalidate fetch fresh content. The wait is skipped when the signature is invalid. To skip it, pass false as the third argument (waitForContentLakeEventualConsistency), for example to keep your handler's response time short. See Caching and revalidation in Next.js for more on how the Sanity CDN and Next.js data cache interact.
Path-based revalidation
Use path-based revalidation when you know which routes correspond to which documents. For example, a blog post at /posts/my-post maps directly to a Sanity document with slug.current === 'my-post'.
Create the API route
// src/app/api/revalidate-path/route.ts
import { revalidatePath } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
type WebhookPayload = {
path?: string
}
export async function POST(req: NextRequest) {
try {
if (!process.env.SANITY_REVALIDATE_SECRET) {
return new Response(
'Missing environment variable SANITY_REVALIDATE_SECRET',
{ status: 500 }
)
}
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true // wait for Content Lake propagation
)
if (!isValidSignature) {
return new Response(
JSON.stringify({ message: 'Invalid signature', isValidSignature, body }),
{ status: 401 }
)
}
if (!body?.path) {
return new Response(
JSON.stringify({ message: 'Bad Request', body }),
{ status: 400 }
)
}
revalidatePath(body.path)
return NextResponse.json({
message: `Revalidated path: ${body.path}`,
body,
})
} catch (err: unknown) {
console.error(err)
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(message, { status: 500 })
}
}Configure the webhook projection
The webhook needs to send a path value that matches your Next.js routes. Use a GROQ projection with the select() function to generate paths dynamically:
{
"path": select(
_type == "post" => "/posts/" + slug.current,
"/" + slug.current
)
}Extend this pattern for your own document types and route structure:
{
"path": select(
_type == "post" => "/posts/" + slug.current,
_type == "author" => "/authors/" + slug.current,
_type == "category" => "/categories/" + slug.current,
"/" + slug.current
)
}To revalidate all routes on demand, create an API route that calls revalidatePath('/', 'layout'). This is a blunt instrument, but useful as a fallback.
Tag-based revalidation
Use tag-based revalidation when a single document change affects many pages. For example, updating an author's name should revalidate every post that displays it.
Tag-based revalidation has two parts:
- Tagging queries: when fetching data, you associate queries with tags (covered in Caching and revalidation in Next.js)
- Busting tags: when content changes, a webhook calls
revalidateTag()to invalidate all queries with that tag
This section covers the webhook side.
Create the API route
// src/app/api/revalidate-tag/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
type WebhookPayload = {
_type: string
}
export async function POST(req: NextRequest) {
try {
if (!process.env.SANITY_REVALIDATE_SECRET) {
return new Response(
'Missing environment variable SANITY_REVALIDATE_SECRET',
{ status: 500 }
)
}
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true // wait for Content Lake propagation
)
if (!isValidSignature) {
return new Response(
JSON.stringify({ message: 'Invalid signature', isValidSignature, body }),
{ status: 401 }
)
}
if (!body?._type) {
return new Response(
JSON.stringify({ message: 'Bad Request', body }),
{ status: 400 }
)
}
revalidateTag(body._type, 'max')
return NextResponse.json({ body })
} catch (err: unknown) {
console.error(err)
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(message, { status: 500 })
}
}How tags connect to queries
When you fetch data using the manual sanityFetch helper with tags:
const posts = await sanityFetch({
query: POSTS_QUERY,
tags: ['post', 'author'],
})Next.js associates the cached response with both the post and author tags. When the webhook fires for a document with _type === 'post', calling revalidateTag('post', 'max') invalidates all cached queries tagged with 'post'. The second argument, 'max', is one of the built-in Next.js cacheLife profiles. For other profiles or an { expire } object, see the Next.js revalidateTag reference.
See Caching and revalidation in Next.js for the full sanityFetch helper implementation and tagging patterns.
Configure the webhook projection
For tag-based revalidation, the projection only needs the document type:
{_type}Set up the webhook in Sanity
Configure the webhook in your Sanity project dashboard:
| Field | Path-based | Tag-based |
|---|---|---|
| URL | https://your-domain.com/api/revalidate-path | https://your-domain.com/api/revalidate-tag |
| Events | Create, Update, Delete | Create, Update, Delete |
| Filter | _type in ["post", "author"] | _type in ["post", "author"] |
| Projection | {"path": select(...)} | {_type} |
| Secret | Your SANITY_REVALIDATE_SECRET | Your SANITY_REVALIDATE_SECRET |
| HTTP method | POST | POST |
Adjust the filter to include the document types your application uses.
Quick setup with webhook templates
Use these shareable templates to create pre-configured webhooks in your Sanity project:
After importing a template, update the URL to point to your deployed application and set the secret.
Security
Webhook endpoints are publicly accessible URLs. Follow these practices to keep them secure:
- Always validate signatures. Never skip the
parseBodyvalidation step, even in development. An unvalidated endpoint lets anyone trigger revalidation (or worse, if you add write operations later). - Use a strong secret. Generate a random string of at least 32 characters. Tools like
openssl rand -base64 32work well. - Keep the secret out of version control. Use
.env.localfor local development and your hosting provider's secrets management for production. - Consider rate limiting. In production, add rate limiting to your revalidation endpoints to prevent abuse. Most hosting providers offer this at the infrastructure level.
Pages Router
Webhook revalidation works with Pages Router API routes, but parseBody only supports App Router route handlers (since next-sanity v9). In a Pages Router API route, validate the signature with @sanity/webhook directly. The Live Content API is App Router only, but webhook-based revalidation is available on both routers.
The key differences from the App Router examples:
- API routes live in
pages/api/instead ofsrc/app/api/ - The handler receives
reqandresobjects instead of aRequest - Use
res.revalidate()instead of importingrevalidatePathfromnext/cache - Validate the signature with
isValidSignaturefrom@sanity/webhook(npm install @sanity/webhook) instead ofparseBody - Disable the built-in body parser with
bodyParser: false, because the signature is checked against the raw request body
// pages/api/revalidate-path.ts
import type { NextApiRequest, NextApiResponse, PageConfig } from 'next'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
type WebhookPayload = {
path?: string
}
// Next.js parses the body by default, which breaks signature validation
export const config: PageConfig = {
api: {
bodyParser: false,
},
}
async function readBody(req: NextApiRequest): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
}
return Buffer.concat(chunks).toString('utf8')
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' })
}
try {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) {
return res.status(500).json({
message: 'Missing environment variable SANITY_REVALIDATE_SECRET',
})
}
const signature = req.headers[SIGNATURE_HEADER_NAME]
const rawBody = await readBody(req)
if (
typeof signature !== 'string' ||
!(await isValidSignature(rawBody, signature, secret.trim()))
) {
return res.status(401).json({ message: 'Invalid signature' })
}
// Wait for Content Lake eventual consistency, as parseBody does
await new Promise((resolve) => setTimeout(resolve, 3000))
const body: WebhookPayload = rawBody.trim() ? JSON.parse(rawBody) : {}
if (!body.path) {
return res.status(400).json({ message: 'Bad Request', body })
}
await res.revalidate(body.path)
return res.status(200).json({
message: `Revalidated path: ${body.path}`,
body,
})
} catch (err: unknown) {
console.error(err)
const message = err instanceof Error ? err.message : 'Unknown error'
return res.status(500).json({ message })
}
}Pages Router doesn't support revalidateTag. For tag-based patterns, you'll need to map document types to paths and use res.revalidate(path) instead. This means tag-based revalidation in Pages Router effectively becomes path-based.
Troubleshoot webhook errors
When a webhook request fails, the route handlers on this page respond with one of these errors:
401 Invalid signaturewithisValidSignature: null: the request has nosanity-webhook-signatureheader, andparseBodylogsMissing signature header. Send test requests from the Sanity webhook rather than calling the route directly.401 Invalid signaturewithisValidSignature: false: the webhook's secret doesn't matchSANITY_REVALIDATE_SECRET. Check that both values are identical, including in your hosting provider's environment variables.500 Missing environment variable SANITY_REVALIDATE_SECRET: the route can't read the secret. Add it to.env.localfor local development and to your hosting provider's environment variables.
Related resources
- Caching and revalidation in Next.js: choosing a caching strategy and implementing the
sanityFetchhelper. - GROQ-powered webhooks: configuring webhooks in the Sanity dashboard.
- GROQ functions reference: reference for
select()and other functions used in webhook projections. - next-sanity overview: what the toolkit includes and how to get started.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?