Use Sanity with Next.js

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.

Prerequisites

  • A Next.js application with next-sanity configured.
  • Next.js 16 or later and next-sanity 12 or later. The tag-based examples use the two-argument revalidateTag call 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_SECRET

Add 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:

ArgumentTypeDescription
requestNextRequestThe incoming webhook request
secretstring (optional)Your SANITY_REVALIDATE_SECRET value. If you omit it, parseBody skips signature validation and returns isValidSignature as null.
waitForContentLakeEventualConsistencyboolean (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: true if the webhook request was signed with the correct secret, false if it wasn't, or null if the request has no signature header or you didn't pass a secret
  • body: the parsed webhook payload, typed as the generic you provide, or null if the request has no signature header or an empty body

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
  )
}

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:

FieldPath-basedTag-based
URLhttps://your-domain.com/api/revalidate-pathhttps://your-domain.com/api/revalidate-tag
EventsCreate, Update, DeleteCreate, Update, Delete
Filter_type in ["post", "author"]_type in ["post", "author"]
Projection{"path": select(...)}{_type}
SecretYour SANITY_REVALIDATE_SECRETYour SANITY_REVALIDATE_SECRET
HTTP methodPOSTPOST

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 parseBody validation 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 32 work well.
  • Keep the secret out of version control. Use .env.local for 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 of src/app/api/
  • The handler receives req and res objects instead of a Request
  • Use res.revalidate() instead of importing revalidatePath from next/cache
  • Validate the signature with isValidSignature from @sanity/webhook (npm install @sanity/webhook) instead of parseBody
  • 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 })
  }
}

Troubleshoot webhook errors

When a webhook request fails, the route handlers on this page respond with one of these errors:

  • 401 Invalid signature with isValidSignature: null: the request has no sanity-webhook-signature header, and parseBody logs Missing signature header. Send test requests from the Sanity webhook rather than calling the route directly.
  • 401 Invalid signature with isValidSignature: false: the webhook's secret doesn't match SANITY_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.local for local development and to your hosting provider's environment variables.

Related resources

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme