Separating Drafts from Production Reads in Sanity: An Architecture Note on GROQ Perspectives
Sanity keeps drafts in the same dataset as published content. The minimal design: one query set, two clients, and the published vs. previewDrafts perspectives as the trust boundary.
27 Sept 2026, 23:01 UTC

If your Sanity-backed site has ever shown an unpublished article to a real visitor, the cause is almost always the same: the production query path and the editorial preview path were never actually separated. Sanity stores drafts and published documents in the same dataset, so "don't leak drafts" is a design decision you have to make explicitly, not a default you get for free. The smallest correct design is to route every read through one of two GROQ perspectives — published for production, previewDrafts for editors — and to treat dataset visibility and CDN choice as the trust boundary underneath that.
Requirements
The system has to satisfy four constraints:
- Public traffic must never receive draft content, even if a query is written carelessly.
- Editors must be able to preview drafts overlaid on published content without duplicating query code.
- Production reads should be cacheable on Sanity's CDN; preview reads must be live.
- Unpublished content that is genuinely sensitive must not be readable by anyone holding the project ID.
How drafts actually exist in a dataset
Sanity does not copy drafts to a separate store. An unpublished edit lives in the same dataset as an overlay document whose ID is prefixed with drafts. — for example, drafts.post-abc123 overlays post-abc123. A document can exist as draft-only, published-only, or both at once. This matters architecturally because a naive GROQ query like *[_type == "post"] matches both IDs. Older client configurations (the historical raw behavior) return drafts and published documents together, which is exactly how draft leaks happen in codebases that predate the perspective option or that filter drafts manually and inconsistently.
The smallest suitable design: two perspectives, one query set
The official JavaScript client accepts a perspective option. Setting it to published makes the API exclude draft overlays automatically; setting it to previewDrafts stacks drafts on top of published documents so the draft version wins where both exist. That gives you the whole design:
// lib/sanity.ts — run in your app server / build, not in the Studio
import { createClient } from '@sanity/client';
export const publishedClient = createClient({
projectId: process.env.SANITY_PROJECT_ID, // e.g. "abc123de"
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: true, // published, cacheable reads
perspective: 'published', // drafts excluded at the API layer
});
export const previewClient = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false, // live API; previews must not be stale
perspective: 'previewDrafts',
token: process.env.SANITY_READ_TOKEN, // required if dataset is private
});
// One query, used by both paths — no draft filtering in GROQ itself:
// *[_type == "post" && slug.current == $slug][0]The front end picks a client based on whether the request is a preview session (typically gated by a signed preview URL or draft-mode cookie), and every GROQ query stays perspective-agnostic. The trust decision lives in one file instead of being smeared across dozens of !(_id in path("drafts.**")) filters.
Trust and data boundaries
Perspectives control what the API returns; dataset visibility controls who may ask. In a public dataset, anyone who knows your project ID (visible in your frontend bundle) can query *[_id in path("drafts.**")] directly and read every draft. The published perspective protects your application from leaking drafts — it does not make drafts secret. If unpublished content is commercially sensitive, the boundary must be a private dataset, where all reads require a token, and preview requests carry a read token server-side. Never ship that token to the browser.
The second boundary is the CDN. apicdn.sanity.io serves cached responses and is correct for published traffic. Preview requests must go to the live API (useCdn: false), or editors will see stale content and conclude preview is broken. The reverse mistake — routing production traffic at the live API — throws away caching and rate-limit headroom for no benefit.
Operational checks
These checks assume a current @sanity/client version; confirm the perspective option exists in your installed version's documentation before relying on it, since defaults have changed across major releases.
- Draft exclusion. Create a draft-only document (never published). Query it via
publishedClientand confirm the result set is empty; query viapreviewClientand confirm it appears. Run this in a small script or API route with your server environment variables. - Dataset boundary. In the project management console, check dataset visibility. Then, from any machine, attempt an unauthenticated request for a
drafts.*ID against the API. On a public dataset it will succeed — decide whether that is acceptable. - CDN routing. Inspect network calls: production pages should hit
apicdn.sanity.io, preview sessions should hitapi.sanity.io. - Cache invalidation. If you use webhooks or listeners to purge a cache, trigger a draft edit and then a publish, and confirm they are handled differently — draft mutations should not purge your published cache, and publish events must.
Failure modes
- Legacy manual filtering. Code written before perspective support often mixes
rawreads with ad-hoc draft filters. Any query that misses the filter leaks. Migrating toperspective: 'published'removes the whole class of bug. - Preview served from CDN. Symptom: editors save and see no change. Cause:
useCdn: trueon the preview client. - Token in the browser. A private dataset with the read token bundled client-side is a public dataset with extra steps.
- Webhook over-purging. Treating draft mutations as publish events makes caches churn on every keystroke-adjacent save; treating publishes as draft events leaves stale pages live.
When this design is not enough
Two conditions justify more machinery. First, if you need click-to-edit Visual Editing overlays, you enter stega-encoded metadata territory, which is version-sensitive — pin and verify client versions rather than assuming behavior. Second, if you need per-user draft visibility (e.g., authors see only their own drafts), perspectives are the wrong tool; that requires private datasets plus server-side authorization on the preview route. For the common case — public site, editor preview — two clients and two perspectives are the entire architecture, and anything larger is probably solving a problem you do not have.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.