Managing Draft vs Published Content States in Frontend
Within Draft State Management, this guide deals with leakage. Draft state leaks into production when a frontend fails to isolate unpublished content from its caches — usually through shared query keys, missing cache-control directives, or sloppy webhook filtering. This page shows how to keep draft and published as separate execution contexts with distinct caching lifecycles, using token-gated routing and cache-key stratification.
Where leakage comes from
The common failure modes are shared query keys, absent cache-control directives, and indiscriminate webhook handling. When a CMS emits a draft-to-published webhook, many Jamstack apps trigger blanket ISR revalidation or full-site rebuilds. That creates race conditions: draft content briefly surfaces on production routes, or published updates stall until the next cache expiry.
The CMS is rarely at fault. The problem is the frontend not holding a hard boundary between ephemeral preview contexts and immutable production ones. Without routing guards, token validation, and conditional data-fetching, draft payloads bleed into static generation.
Two parallel pipelines
Split content resolution. The production pipeline serves published content via SSG or ISR with aggressive CDN caching for low latency. The preview pipeline runs on demand, bypasses edge caches, and uses short-lived tokens to fetch draft variants straight from the CMS API. Aligning both with Preview & Draft Workflow Patterns keeps transitions predictable and gives editors accurate real-time feedback without risking production.
The two pipelines diverge at the cookie set by the server-side token exchange:
Enforce three invariants:
- Draft routes are never statically generated at build time.
- Preview tokens are validated server-side before any data resolution.
- Cache keys include state identifiers (
draft=truevspublished=true) to prevent collisions.
Token-gated preview routing
Client-side routing guards are trivially bypassed and offer zero protection against cache poisoning. Use a server-side token exchange that sets an httpOnly, Secure cookie with a short expiry. That cookie is the state flag for downstream fetches.
// app/api/preview/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
const slug = searchParams.get('slug');
const secret = process.env.PREVIEW_SECRET;
// Validate against environment-stored secret
if (token !== secret || !slug) {
return NextResponse.json({ error: 'Invalid preview request' }, { status: 401 });
}
const cookieStore = await cookies();
cookieStore.set('preview_mode', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 30, // 30 minutes
sameSite: 'lax'
});
// Redirect to the requested slug in preview mode
return NextResponse.redirect(new URL(slug, request.url));
}
Server components and route handlers then inspect the cookie before querying. When preview_mode is set, the data layer switches to draft endpoints, appends ?status=draft, and disables response caching — the server-side-first approach the Next.js Draft Mode docs recommend over client-side routing.
Cache-key stratification
Key collisions are the primary leakage vector. A CDN typically hashes path plus query parameters; if draft and published requests hash identically, the first cached response serves both. Stratify explicitly:
- Production:
GET /posts/my-article→public, max-age=3600, s-maxage=86400 - Preview:
GET /posts/my-article?preview=true→private, no-store, max-age=0
Configure the CDN to Vary on the auth cookie. Enforcing Draft State Management at the data-fetching layer keeps draft payloads out of shared cache buckets.
Scope webhook filtering precisely too. Instead of a blanket revalidatePath('/'), parse the payload for the affected document ID and slug and call targeted revalidation. That shrinks the invalidation blast radius and removes race conditions during high-frequency updates.
Operational guardrails
Provision content teams with deterministic preview URLs generated by the CMS rather than hand-appended query parameters. Add lint rules that flag client-side fetches targeting draft endpoints in production builds.
For accessibility compliance, keep preview routes structurally identical to published ones — same DOM, same ARIA landmarks. State toggling must never alter semantic markup, since screen readers depend on a consistent hierarchy. During legacy decoupling, map legacy draft states onto the token-gated pipeline through a middleware translation layer to keep backward compatibility.
Token validation, explicit cache stratification, and targeted webhook routing together isolate ephemeral draft state from immutable production caches — eliminating leakage, accelerating editorial workflows, and preserving Jamstack performance guarantees.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Preview cookie | httpOnly, Secure, SameSite=Lax, 30 min |
Unreadable by scripts, sent on navigations, short-lived. |
| Token comparison | constant-time, server-side | Prevents timing attacks on the preview secret. |
| Preview responses | private, no-store, X-Robots-Tag: noindex |
Never cached, never indexed. |
| CDN rule | bypass when preview cookie present | Edge-level backstop for the origin headers. |
| Webhook topics | publish, unpublish, archive, scheduled publish | Draft saves never touch production caches. |
| Fetch helper | one function selects host, token, params, cache | Removes per-component decisions. |
The token comparison in the route above uses !==, which is fine for a demo but leaks timing information in principle. Use crypto.timingSafeEqual on equal-length buffers, and prefer per-entry signed tokens over one shared secret, as described in token-based preview authentication.
Gotchas & Edge Cases
- Query-string preview flags. A
?preview=trueparameter can be copied into a public link or stripped by a CDN key policy. Use it only to start the token exchange, never as the ongoing preview signal. - Static params that include drafts. Generating static paths from an API call made with the preview token builds draft pages into the public site. Generate paths only with the delivery token.
- Draft cookies on other routes. A preview cookie with
path: /also affects API routes and assets. Scope behaviour carefully: API routes that must stay cacheable should ignore the preview cookie. - Framework draft mode versus your cookie. In Next.js,
draftMode().enable()sets its own bypass cookie; do not invent a second cookie with different semantics. ReaddraftMode().isEnabledin the fetch helper instead. - Exiting preview. Provide a visible “exit preview” link that clears the cookie, or editors will browse the live site in preview mode and report stale published content as a bug.
Worked Example
A publisher saw a half-finished investigative article appear on its homepage teaser for eleven minutes. An editor had opened the preview with ?preview=true, the homepage’s teaser component fetched the latest article list with the preview token because it read the query parameter directly, and the CDN, which ignored query strings in its key, cached the homepage. The fix combined the patterns on this page: the preview signal moved to the framework’s draft-mode cookie, all fetches went through one helper that chose token and cache policy together, the CDN bypassed requests carrying the cookie, and the teaser list was regenerated only by publish webhooks. A regression test now opens a preview in one browser context and asserts that an anonymous context never sees the draft headline.
Rollout Checklist
- Replace every direct CMS fetch with the shared helper that reads draft mode.
- Move preview signals from query strings to a server-set cookie or draft mode.
- Send
private, no-storeandnoindexon every preview response. - Add a CDN rule that bypasses the cache when the preview cookie is present.
- Filter webhooks so only publish-type events touch production caches.
- Add the two-context leak test to the end-to-end suite.
Frequently Asked Questions
Is Vary: Cookie enough to separate draft and published caches?
It separates them, but it also gives every visitor with any cookie a private cache entry, which ruins hit ratios. Bypass the cache for preview requests instead, and strip unrelated cookies from anonymous requests.
Can drafts be previewed on statically exported sites?
Not from the static output itself. Use a small preview deployment or serverless route that renders the same templates with draft data, protected by the same token exchange.
What if two editors preview different drafts of the same page?
Each preview request renders on demand with that editor’s session, so there is no shared state between them. The only shared layer, the CMS itself, returns the latest draft to both, which is the expected collaborative behaviour.
How do I audit an existing codebase for draft leaks?
Search for every place that reads a preview token or preview flag, and every direct call to the CMS API outside the shared helper. Each hit is a potential leak until it goes through the helper. Then run the two-context test against staging for the homepage, a listing page and an entry page, because those combine the most data sources.
Should preview use the same components as the live site?
Yes. Preview is only trustworthy if it renders with the same components, layouts and styles as production. Differences, even small ones such as a missing font or a different container width, make editors doubt what they see and approve content that breaks on the live site.
How do I handle preview for pages that are not in the CMS?
Pages built purely from code, such as a pricing calculator, have no draft state. Render them normally in preview, and show the preview banner so editors understand that only CMS-backed content on the page reflects drafts.