DEV FIELDNOTES
Sanity field guide 003Updated July 22, 2026

Sanity published the update, but Next.js still shows stale content.

A production guide to signed Sanity webhooks, precise Next.js cache tags, path revalidation, slug changes, deletes, and proving that fresh content reached Vercel.

A publishing pipeline where a content document sends a webhook through a security gate, breaks a stale cache layer, and refreshes a webpage

The editor publishes a Sanity document, the webhook reports a successful delivery, and the production Next.js page still shows the previous version. Refreshing does nothing. Redeploying fixes it, which makes the CMS look unreliable even though the document is already correct in the Content Lake.

This failure lives between a mutation and the cached page that readers receive. A reliable implementation must connect four contracts: the query has the right cache tags, Sanity sends a focused and authenticated event, the Route Handler invalidates every affected cache entry, and the next request regenerates the correct routes. Debug those contracts in order instead of purging every cache at once.

A webhook does not refresh a page by itself

A Sanity webhook is only an HTTP request describing a content change. Next.js must validate that request and explicitly invalidate the cached data or route. If the query was never tagged, revalidateTag has nothing to target. If only the article tag is invalidated, the homepage and guides archive can remain stale. If a slug changes, the old URL also needs deliberate handling.

Model the affected surfaces first

For a guide mutation, this site must consider the individual article, the homepage list, the guides archive, and the sitemap. Your application may have category pages, feeds, search indexes, or related-content blocks too.

Tag the Sanity reads you intend to invalidate

Use a collection tag for shared lists and a document-specific tag for individual pages. Tag names are case-sensitive, must match exactly, and should remain stable across the webhook and data layer.

lib/sanity.ts
export async function getArticle(slug: string) {
  return client.fetch(ARTICLE_QUERY, {slug}, {
    next: {tags: ['techArticles', `techArticle:${slug}`]},
  })
}

export async function getGuideArticles() {
  return client.fetch(GUIDES_QUERY, {}, {
    next: {tags: ['techArticles', 'techGuides']},
  })
}

The broad techArticles tag gives you a safe shared invalidation boundary. The techGuides tag isolates guide listings, while techArticle:<slug> targets one article. Do not create tags from mutable titles. Slugs or immutable document IDs are predictable enough to reproduce in the webhook handler.

Create a signed Route Handler

The endpoint is public because Sanity must reach it, but its action must not be public. Validate the signature with a server-only secret before calling any revalidation function. Never place the webhook secret behind a NEXT_PUBLIC_ prefix.

.env.local
SANITY_REVALIDATE_SECRET=replace-with-a-long-random-secret
app/api/revalidate/route.ts
import {revalidatePath, revalidateTag} from 'next/cache'
import {parseBody} from 'next-sanity/webhook'

type Payload = {_id?: string; slug?: string; previousSlug?: string}

export async function POST(request: Request) {
  const secret = process.env.SANITY_REVALIDATE_SECRET
  if (!secret) return Response.json({message: 'Missing secret'}, {status: 500})

  const {body, isValidSignature} = await parseBody<Payload>(request, secret)
  if (!isValidSignature) {
    return Response.json({message: 'Invalid signature'}, {status: 401})
  }

  revalidateTag('techArticles', 'max')
  revalidateTag('techGuides', 'max')

  if (body?.slug) {
    revalidateTag(`techArticle:${body.slug}`, 'max')
    revalidatePath(`/guides/${body.slug}`)
  }
  if (body?.previousSlug && body.previousSlug !== body.slug) {
    revalidateTag(`techArticle:${body.previousSlug}`, 'max')
    revalidatePath(`/guides/${body.previousSlug}`)
  }

  revalidatePath('/')
  revalidatePath('/guides')
  revalidatePath('/sitemap.xml')
  return Response.json({revalidated: true, now: Date.now()})
}
Use the current profile argument

The one-argument immediate-expiration form is deprecated in current Next.js. For webhook-driven content, revalidateTag(tag, "max") uses stale-while-revalidate. updateTag is for read-your-own-writes in Server Actions and cannot be called from this Route Handler.

Configure a narrow Sanity webhook

Create a document webhook in the Sanity project management interface. Use the production endpoint, POST, the production dataset, and the same secret configured in Vercel. Trigger only on create, update, and delete events for the content type this handler understands.

Webhook URL
https://YOUR_DOMAIN/api/revalidate
Webhook filter
_type == "techArticle"
Webhook projection
{
  _id,
  "slug": coalesce(after().slug.current, before().slug.current),
  "previousSlug": before().slug.current
}

The projection keeps the payload small and preserves the previous slug. That matters on delete events and slug changes, where after() may not contain the route that was previously cached. Sanity ignores drafts and versions by default unless those options are explicitly enabled, which is appropriate for a published-site webhook.

Understand what “max” means during verification

revalidateTag with the max profile marks tagged data stale. The next visit can receive the stale value while Next.js refreshes it in the background; a following request receives the fresh value. That first stale response is expected stale-while-revalidate behavior, not proof that the webhook failed.

revalidatePath handles the route-level output around the data cache. Using both is intentional here: tags express which Sanity reads changed, while paths cover the pages and metadata routes that assemble those reads. Keep the invalidation set specific enough that one edit does not regenerate the entire application.

Run the production verification sequence

1. Prove the mutation is published

Query the production dataset with perspective: published and useCdn: false. If the new value is absent there, stay in Sanity and do not debug Next.js yet.

2. Inspect the Sanity delivery attempt

Open the webhook attempts log. Confirm the expected event triggered, the URL is the production domain, the response is in the 200 range, and the payload contains the current and previous slugs. A 401 means signature or secret mismatch. A 404 means the deployed route path is wrong.

3. Log the invalidation boundary

Temporarily log the webhook document ID, slug, previousSlug, tags, and paths being invalidated. Do not log the secret or full signature. This proves the handler did more than return a successful status.

Temporary diagnostic
console.info('Sanity revalidation', {
  id: body?._id,
  slug: body?.slug,
  previousSlug: body?.previousSlug,
  tags: ['techArticles', 'techGuides'],
})

4. Request every affected surface twice

Open the article, homepage, guides archive, and sitemap. Because max uses stale-while-revalidate, request each surface again after the first response. Confirm the article copy, updated date, list order, links, and sitemap URL all agree.

5. Test a slug change and deletion

Change a disposable document slug and verify the new URL appears while the old URL stops resolving or redirects according to your policy. Then delete or unpublish it and verify it disappears from lists and the sitemap. Create and update tests alone do not prove lifecycle correctness.

Failure patterns that look alike

The webhook never appears in the attempts log

The filter, dataset, event selection, or webhook status is wrong. Confirm the mutation matches _type == "techArticle" and the webhook is enabled for the dataset you are editing.

The webhook returns 401

The secret configured in Sanity does not match the deployed SANITY_REVALIDATE_SECRET, or the request body was consumed before parseBody verified it. Environment variables are isolated between Vercel production and preview deployments, so verify the correct environment and redeploy after changing the secret.

The handler returns 200, but nothing changes

The tag strings likely do not match the tags attached to the fetch, or the page uses a different untagged query. Log both sides exactly. Also confirm the route is deployed in the same Vercel project and environment as the cached application you are testing.

The article updates, but lists remain stale

Only the document-specific tag or article path was invalidated. Add the shared collection tag and revalidate the homepage, archive, feed, or category paths that include the document.

The first refresh is stale, and the second is fresh

That is the expected max profile. updateTag is limited to Server Actions. For a webhook Route Handler, design the verification around stale-while-revalidate or choose an appropriate custom cache-life profile.

It works on a Vercel preview URL but not the custom domain

Production and preview are separate deployments with separate environment variables and caches. Point the Sanity webhook at the canonical production domain, scope the secret to Production, and confirm that the domain resolves to the intended Vercel project.

The reliable publishing contract

A production CMS integration should not depend on redeployment to expose a published edit. Tag every cached read intentionally, authenticate a narrow webhook, invalidate both document and collection surfaces, preserve old route information, and verify the production response under the cache semantics you selected.

Make revalidation observable

Keep Sanity attempts, structured server logs, stable tag names, and a repeatable publish test. When each boundary can be observed independently, stale content becomes a specific failed contract instead of a mysterious cache problem.

Next.js revalidateTagNext.js revalidatePathNext.js revalidation guideSanity GROQ-powered webhooksSanity and Next.js