I audited our CMS and 86% of our articles were invisible. A Sanity gotcha.
Silent Draft Leakage in Headless CMS: Architecting Reliable Content Promotion with Sanity
Current Situation Analysis
Headless CMS platforms abstract away database complexity, but that abstraction often masks underlying state management mechanics. When engineering teams treat a CMS client as a simple key-value fetcher, they frequently overlook how document versioning, draft overlays, and authentication scopes interact at read time. This oversight becomes critical in editorial workflows where content must transition cleanly from draft to published state.
The core pain point is silent state divergence. A CMS may report successful mutations, approval handlers may return 200 OK, and frontend queries may execute without errors. Yet, the public surface remains empty. This happens because default client behaviors rarely align with production read requirements, especially when datasets operate in private-read mode and require authenticated tokens.
Why is this problem consistently misunderstood? Three factors compound the blindness:
- Default Perspective Assumptions: Most SDKs default to a draft-overlay perspective when a token is present. Developers assume the client returns the canonical published state, but it actually returns whichever layer (draft or published) exists last.
- Mutation Semantics Confusion: Patching a document updates its current layer. It does not promote it. Teams frequently conflate state flags (e.g.,
status: "approved") with actual publication mechanics. - Query Filter Gaps: Without explicit draft exclusion, GROQ or GraphQL resolvers will surface draft IDs alongside published ones, creating duplicate slugs or invisible content depending on how the overlay resolves.
Real-world telemetry confirms the scale of the issue. In a production editorial pipeline running for nine days, an automated audit revealed that 86% of approved content was trapped in draft state. The approval workflow executed correctly, status flags updated accurately, and no errors surfaced in logs. The content was simply invisible to readers because the read client defaulted to draft overlay and the promotion handler only patched in place.
This is not a platform defect. It is an architectural mismatch between default SDK behavior and production read requirements. When datasets require authenticated reads, the client must be explicitly configured to ignore draft layers, and promotion workflows must perform atomic state transitions rather than in-place mutations.
WOW Moment: Key Findings
The compounding nature of these misconfigurations creates a silent failure mode that only surfaces during manual audits or SEO crawl analysis. The following table isolates the behavioral differences between default configurations and production-hardened approaches:
| Approach | Draft Visibility | Promotion Mechanism | Query Safety | State Consistency |
|---|---|---|---|---|
| Default SDK + Patch Handler | Drafts overlay published | In-place flag update | Unfiltered GROQ | High divergence risk |
Explicit published Perspective + Atomic Promotion |
Drafts strictly excluded | Create published ID + delete draft | Draft-path exclusion filter | Near-zero divergence |
| Token-Scoped Reads + Draft Overlay | Mixed/Unpredictable | Manual or missing | Partial filters | Moderate risk |
The critical insight is that default perspective behavior and in-place patching are functionally orthogonal to publication requirements. When combined, they create a state black hole: content passes business logic checks, updates correctly in the draft layer, but never materializes in the published layer. Readers query the published layer, find nothing, and the system reports success.
This finding enables three architectural improvements:
- Explicit perspective binding eliminates overlay ambiguity at the client level
- Atomic promotion transactions guarantee draft-to-published state transitions
- Query-level draft exclusion provides defense-in-depth against client misconfiguration
Understanding this separation of concerns transforms CMS integration from a fetch-and-render pattern into a state-machine workflow with explicit lifecycle boundaries.
Core Solution
Building a reliable content promotion pipeline requires isolating three concerns: client configuration, query hardening, and atomic state transitions. Each layer must operate independently while enforcing the same publication contract.
Step 1: Client Configuration with Explicit Perspective
When a dataset operates in private-read mode, authenticated reads are mandatory. The SDK will default to a draft-overlay perspective unless explicitly overridden. This default is optimized for editing interfaces, not public consumption.
Production clients must bind the published perspective at instantiation. This ensures all subsequent queries ignore draft layers entirely, regardless of token scope or dataset configuration.
import { createClient, type SanityClient } from '@sanity/client';
export interface EditorialClientConfig {
projectId: string;
dataset: string;
apiVersion: string;
readToken: string;
useCdn: boolean;
}
export function buildPublicClient(config: EditorialClientConfig): SanityClient {
return createClient({
projectId: config.projectId,
dataset: config.dataset,
apiVersion: config.apiVersion,
token: config.readToken,
useCdn: config.useCdn,
perspective: 'published',
resultMode: 'experimental_simpleQuery',
});
}
Why this choice: Binding perspective: 'published' at the client level establishes a hard boundary. Even if downstream code accidentally passes draft IDs or omits filters, the client will never resolve draft layers. The experimental_simpleQuery mode reduces payload overhead for public routes, and explicit token scoping prevents write-token leakage into read paths.
Step 2: Query Hardening with Draft Exclusion
Client-level perspective binding is necessary but insufficient. Defense-in-depth requires query-level draft exclusion. This protects against ad-hoc client instantiation, third-party integrations, or future SDK behavior changes.
GROQ provides a deterministic path filter for draft identification. Embedding this filter into base queries ensures draft documents are excluded at the database level.
const DRAFT_EXCLUSION_FILTER = `!(_id in path("drafts.**"))`;
export const buildArticleQuery = (statusFilter: string = '') => {
const base = `
*[_type == "editorial.content" && defined(slug.current) && ${DRAFT_EXCLUSION_FILTER}]
`;
const statusClause = statusFilter
? ` && (status == "${statusFilter}" || !defined(status))`
: '';
return `${base}${statusClause} | order(publishedAt desc) {
_id,
slug,
title,
excerpt,
publishedAt,
"author": author->name
}`;
};
Why this choice: Separating the draft filter into a constant enables reuse across multiple query builders. The conditional status clause maintains flexibility for internal dashboards while keeping public queries strict. Ordering by publishedAt rather than _createdAt ensures chronological accuracy for editorial workflows.
Step 3: Atomic Promotion Workflow
Patching a draft document updates its draft layer. It does not create a published version. Promotion requires fetching the draft, stripping metadata, creating a new document at the bare ID, and removing the draft. This must be handled idempotently to prevent race conditions during concurrent approvals.
import type { SanityClient, PatchOperations } from '@sanity/client';
export interface PromotionResult {
success: boolean;
publishedId: string;
draftId: string;
error?: string;
}
export async function promoteDraftToPublished(
client: SanityClient,
draftId: string
): Promise<PromotionResult> {
if (!draftId.startsWith('drafts.')) {
return { success: false, publishedId: '', draftId, error: 'Invalid draft ID format' };
}
const publishedId = draftId.replace(/^drafts\./, '');
try {
const draft = await client.getDocument(draftId);
if (!draft) {
return { success: false, publishedId, draftId, error: 'Draft not found' };
}
const { _id, _rev, _createdAt, _updatedAt, _type, ...content } = draft;
const publishedDoc = {
...content,
_id: publishedId,
_type: 'editorial.content',
status: 'published',
promotedAt: new Date().toISOString(),
};
await client.createOrReplace(publishedDoc);
await client.delete(draftId);
return { success: true, publishedId, draftId };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown promotion error';
return { success: false, publishedId, draftId, error: message };
}
}
Why this choice: createOrReplace ensures idempotency. If a published version already exists (e.g., from a manual edit or retry), it overwrites safely without throwing conflicts. Stripping _rev, _createdAt, and _updatedAt prevents metadata collision. The explicit status transition to published maintains auditability. Error handling returns structured results rather than throwing, enabling graceful degradation in webhook handlers.
Step 4: Idempotent Backfill Strategy
Existing draft backlogs require a reconciliation script that mirrors the promotion logic but operates at scale. The script must skip already-published twins, handle rate limits, and provide progress tracking.
import type { SanityClient } from '@sanity/client';
export async function reconcileApprovedDrafts(client: SanityClient): Promise<void> {
const query = `
*[_type == "editorial.content" && _id in path("drafts.**") && status == "approved"]
| order(_createdAt asc)
`;
const drafts = await client.fetch(query);
const batchSize = 50;
for (let i = 0; i < drafts.length; i += batchSize) {
const batch = drafts.slice(i, i + batchSize);
await Promise.all(
batch.map(async (draft: any) => {
const publishedId = draft._id.replace(/^drafts\./, '');
const exists = await client.fetch(
`*[_id == $id][0]{ _id }`,
{ id: publishedId }
);
if (exists) return;
const { _id, _rev, _createdAt, _updatedAt, ...rest } = draft;
await client.createOrReplace({ ...rest, _id: publishedId, _type: 'editorial.content' });
await client.delete(draft._id);
})
);
console.log(`Processed ${Math.min(i + batchSize, drafts.length)}/${drafts.length} drafts`);
await new Promise(res => setTimeout(res, 1000));
}
}
Why this choice: Batch processing prevents API rate limit exhaustion. The existence check prevents overwriting manual published edits. The 1-second delay between batches respects Sanity's concurrency limits. Structured logging enables monitoring without flooding production logs.
Pitfall Guide
1. Assuming patch() Promotes Content
Explanation: Patching updates the current document layer. If the ID includes the drafts. prefix, only the draft layer changes. The published layer remains untouched.
Fix: Always use createOrReplace at the bare ID for promotion. Treat patching as draft-only state management.
2. Relying on Default Perspective with Authenticated Reads
Explanation: When a token is present, the SDK defaults to draft overlay. This returns whichever layer exists last, causing drafts to leak into public queries.
Fix: Explicitly set perspective: 'published' on all public-facing clients. Never assume default behavior aligns with read requirements.
3. Missing Draft ID Filters in GROQ
Explanation: Without !(_id in path("drafts.**")), queries return draft IDs alongside published ones. This causes duplicate slugs, preview conflicts, or invisible content depending on overlay resolution.
Fix: Embed draft exclusion as a constant in all base queries. Treat it as a non-negotiable filter for public routes.
4. Overwriting Published Versions During Promotion
Explanation: Blindly creating published documents without checking for existing versions can erase manual edits, SEO metadata, or analytics history. Fix: Always verify published ID existence before promotion. Skip or log conflicts rather than forcing overwrites.
5. Race Conditions in Concurrent Approval Workflows
Explanation: Multiple approval requests hitting the promotion endpoint simultaneously can cause duplicate published documents or failed deletions.
Fix: Implement idempotency keys, use createOrReplace, and add database-level unique constraints on published IDs. Queue promotions if concurrent writes are expected.
6. Token Scope Mismatch
Explanation: Using write tokens for read operations violates least-privilege principles and increases blast radius if credentials leak. Fix: Maintain separate read and write tokens. Bind read tokens to public clients and write tokens to admin/webhook handlers only.
7. Skipping Audit Trails for State Transitions
Explanation: Silent promotions make debugging impossible. When content disappears or duplicates appear, there's no record of what changed and when. Fix: Log every promotion event with draft ID, published ID, timestamp, and trigger source. Store transition history in a separate audit collection if compliance requires it.
Production Bundle
Action Checklist
- Configure public client with
perspective: 'published'and read-only token - Embed
!(_id in path("drafts.**"))filter into all base GROQ queries - Replace in-place patch handlers with atomic promotion workflows
- Implement idempotent
createOrReplacelogic for draft-to-published transitions - Add existence checks before promotion to prevent overwriting manual edits
- Schedule monthly content audits comparing draft vs published counts
- Isolate read and write tokens by environment and route type
- Log all state transitions with draft/published IDs and timestamps
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Public website reads | perspective: 'published' + draft exclusion filter |
Guarantees draft isolation, prevents overlay leaks | Zero (SDK config) |
| Admin dashboard reads | Default perspective or perspective: 'drafts' |
Requires draft visibility for editing workflows | Zero |
| High-volume approval pipeline | Queue-based promotion with idempotency keys | Prevents race conditions, handles retries safely | Moderate (queue infrastructure) |
| Legacy draft backlog | Batch reconciliation script with existence checks | Safely promotes without overwriting manual edits | Low (one-time compute) |
| Multi-region deployment | CDN-enabled client with useCdn: true |
Reduces latency, caches published state | Low (CDN egress) |
Configuration Template
// sanity/client.ts
import { createClient, type SanityClient } from '@sanity/client';
const SANITY_CONFIG = {
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
apiVersion: '2024-10-01',
readToken: process.env.SANITY_READ_TOKEN!,
writeToken: process.env.SANITY_WRITE_TOKEN!,
useCdn: process.env.NODE_ENV === 'production',
};
export const publicClient: SanityClient = createClient({
projectId: SANITY_CONFIG.projectId,
dataset: SANITY_CONFIG.dataset,
apiVersion: SANITY_CONFIG.apiVersion,
token: SANITY_CONFIG.readToken,
useCdn: SANITY_CONFIG.useCdn,
perspective: 'published',
resultMode: 'experimental_simpleQuery',
});
export const adminClient: SanityClient = createClient({
projectId: SANITY_CONFIG.projectId,
dataset: SANITY_CONFIG.dataset,
apiVersion: SANITY_CONFIG.apiVersion,
token: SANITY_CONFIG.writeToken,
useCdn: false,
perspective: 'drafts',
});
// sanity/queries.ts
export const DRAFT_FILTER = `!(_id in path("drafts.**"))`;
export const getPublishedArticles = (limit = 12) => `
*[_type == "editorial.content" && defined(slug.current) && ${DRAFT_FILTER}]
| order(publishedAt desc) [0...${limit}] {
_id,
slug,
title,
excerpt,
publishedAt,
"author": author->name
}
`;
// sanity/promotion.ts
export const PROMOTION_QUERY = `
*[_type == "editorial.content" && _id in path("drafts.**") && status == "approved"]
| order(_createdAt asc)
`;
Quick Start Guide
- Initialize environment variables: Set
SANITY_PROJECT_ID,SANITY_DATASET,SANITY_READ_TOKEN, andSANITY_WRITE_TOKEN. Generate read-only tokens in Sanity dashboard under API settings. - Replace default client instantiation: Swap existing client creation with the
publicClienttemplate. Ensureperspective: 'published'is explicitly set. - Update base queries: Inject
DRAFT_FILTERinto all public GROQ queries. Verify slug uniqueness and published date ordering. - Refactor approval handlers: Replace
patch()calls with the atomic promotion workflow. Add idempotency checks and error logging. - Run reconciliation: Execute the backfill script against production. Monitor logs for skipped duplicates and successful promotions. Verify published count matches expected volume.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
