DEV FIELDNOTES
Sanity field guide 003Updated July 27, 2026

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

A practical checklist for tracing stale Sanity content through GROQ, the API CDN, Next.js caching, signed webhooks, cache tags, and production on Vercel.

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

If you have used Sanity as a CMS with Next.js for any length of time, you have probably encountered content that remains stale after you publish an update. Sometimes the cause is buried in the caching configuration. Other times, the answer is much simpler—and less obvious. That has certainly been my experience.

After troubleshooting this problem more than once, I developed a checklist for tracing content from Sanity Studio to the rendered page. Following the content through each stage helps you find the failing layer before changing cache settings unnecessarily.

The failure lives somewhere 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.

Trace the content before touching the webhook

Start by proving how far the content travelled. If it never left Sanity, a perfect revalidation endpoint cannot make it appear. If the GROQ query cannot select it, clearing every Next.js cache will only regenerate the same incomplete result.

1. Confirm that the document is published

The document must exist in the dataset used by the production frontend and have a published version. Sanity keeps drafts separate from published content, and an unauthenticated production query will not return a draft. Also check the project ID and dataset name. Publishing to staging while looking at production produces a convincing cache problem that no cache change can fix.

2. Ask Sanity for the current value

Run a direct query with perspective: published and useCdn: false. This bypasses the API CDN and asks the live API for the freshest published data. If the current value appears there but not when useCdn is true, you have isolated the first caching boundary.

Treat “wait a couple of minutes” as a test, not a fix

The API CDN can briefly serve a cached query result, so waiting and trying again is reasonable. Do not make an assumed delay part of the publishing contract. Webhook verification and direct API queries give you evidence instead of a timer.

3. Run the GROQ query in Vision

Use Vision in Sanity Studio to run the same GROQ query with the same parameters as the application. Check the document type, slug, category and date filters, and confirm that every referenced document required by the query is also published. If the document does not appear in Vision, stay with the query; Next.js has not failed yet.

4. Identify every cache involved

Write down how the page is rendered and refreshed. It may be statically generated, dynamically rendered, revalidated after a fixed interval, or backed by tagged server data. It may also fetch in the browser with SWR. SWR is a client-side data-fetching library with its own cache and revalidation behaviour, not a Next.js rendering layer, and an application can use both server caching and SWR at the same time.

Only after these checks pass should you move to the webhook. At that point you know the document is published, the query can see it, and the remaining stale value belongs to a known cache boundary.

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 the max profile 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.jsSanity API CDNSanity draftsNext.js client-side fetching with SWR