Why doesn't my published Sanity document show on my site when GROQ returns it?
Vision and your website don't send the same request. Vision runs authenticated queries against the live API, using whatever dataset, API version and perspective you picked in its dropdowns. Your site probably uses a different client configuration, may read through the API CDN, and then caches the result in your framework (a static build, ISR or the Next.js data cache). To fix it, find the layer that's serving old data and invalidate it when you publish.
1. Perspective and token: is the site asking for the same documents?
A perspective decides which document versions a query can see (Perspectives):
| Perspective | Returns |
|---|---|
published | Published documents only, with no drafts. This is the default from API version 2025-02-19. |
drafts | Drafts treated as published, including drafts that were never published |
raw | Drafts, versions and published documents side by side. This was the default before 2025-02-19. |
Vision uses your logged-in Studio credentials. A frontend client usually makes unauthenticated requests, and those only return publicly queryable documents. That's why drafts can show up in Vision but not on your site (Vision lesson). Unauthenticated requests never return drafts or release versions, even from a public dataset (Vision plugin). If the document only appears in Vision with a drafts or raw perspective, it hasn't been published. Also compare the dataset and API version in Vision's dropdowns with your client config.
2. API CDN or live API
useCdn: falsesends queries toapi.sanity.io, which is uncached and always fresh.useCdn: truesends queries toapicdn.sanity.io, which is cached and faster. The CDN is flushed when you publish, but there's a short delay (API CDN).
Static builds should use the live API. You can override the setting for a single request with .withConfig({useCdn: false}) (caching course). Queries that use the drafts perspective are never cached in the CDN, so set useCdn: false explicitly for those.
3. Framework caching
The Sanity CDN and the Next.js data cache are two separate caches, and stale content can come from either one or both. On hosts like Vercel, the Next.js cache survives redeploys (Next.js caching guide). A common helper:
export async function sanityFetch<const QueryString extends string>({
query, params = {}, revalidate = 60, tags = [],
}: { query: QueryString; params?: QueryParams; revalidate?: number | false; tags?: string[] }) {
return client.fetch(query, params, {
next: { revalidate: tags.length ? false : revalidate, tags },
})
}When you pass tags, the query stays cached until revalidateTag() is called. Tags and time-based revalidation are mutually exclusive.
4. Invalidate on publish
Webhook plus a revalidate route. Create a GROQ-powered webhook with Create, Update and Delete events, a filter such as _type in ["post", "author"], the projection {_type}, POST as the method, and a secret (webhook guide):
// src/app/api/revalidate-tag/route.ts
import { revalidateTag } from 'next/cache'
import { NextResponse, type NextRequest } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
export async function POST(req: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) return new Response('Missing environment variable SANITY_REVALIDATE_SECRET', { status: 500 })
// true = wait for the Content Lake/CDN to catch up before revalidating
const { isValidSignature, body } = await parseBody<{ _type: string }>(req, secret, true)
if (!isValidSignature) return new Response('Invalid signature', { status: 401 })
if (!body?._type) return new Response('Bad Request', { status: 400 })
revalidateTag(body._type)
return NextResponse.json({ revalidated: body._type })
}Tag your queries to match, for example sanityFetch({ query: POSTS_QUERY, tags: ['post', 'author'] }). For path-based revalidation, project {"path": select(...)} and call revalidatePath(body.path) instead. In the Pages Router, call res.revalidate(path), because revalidateTag isn't supported there.
Pass true as the third argument to parseBody. Webhooks fire before the CDN updates, so without the delay your revalidation can refill the cache with stale data.
Live Content API (Sanity's default recommendation). In the App Router with next-sanity v13+, use defineLive from next-sanity/live, fetch data with its sanityFetch, and render <SanityLive /> in the root layout (live content guide). Don't mix it with the manual sanityFetch helper above. Your frontend's origin must be in the project's CORS origins. By default, updates are stale-while-revalidate, so a visitor may need to refresh to see a change.
Checklist
- In Vision, set the perspective to
publishedand match the dataset and API version to your client config. If the document disappears, it isn't published, or the frontend is reading a different dataset. - Turn on
logging: { fetches: { fullUrl: true } }innext.config.ts. A host ofapicdn.sanity.iomeans the request goes through the CDN.HITmeans the Next.js cache served the response. - You still get unexpected
HITs after publishing: the webhook isn't firing or the tags don't match. Check the webhook's delivery attempts, and remember that webhooks can't reachlocalhost(expose it with something like ngrok). - The data is stale even after the webhook fires: the CDN served old data. Use
parseBody(..., true)oruseCdn: false. - The site is a fully static build: nothing changes until you rebuild it.
Was this answer helpful?
Sanity – Build the way you think, not the way your CMS thinks
Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.