Draft State Management in Headless CMS Frontends

Draft state management is the discipline of surfacing unpublished content to authorized reviewers without poisoning production caches or leaking drafts to public endpoints. It needs strict query segmentation, deterministic routing, and cache isolation — done right, it preserves static/edge performance while enabling fluid editorial review. It sits at the center of Preview & Draft Workflow Patterns, where state boundaries directly shape developer velocity and content delivery SLAs.

Integration Contract

Every CMS exposes draft content differently, and the frontend’s first job is to hide those differences behind one state machine. Contentful serves drafts from a separate Preview API host with its own token. Sanity returns drafts as documents with a drafts. id prefix, selected by a perspective parameter on the query. Storyblok selects drafts with version=draft on the same API. Strapi uses status=draft in v5, and publicationState=preview in v4. Hygraph uses a stage: DRAFT argument on GraphQL queries. Directus stores state in a status field that your query filters on.

The contract the rest of the frontend relies on is therefore small: a single ContentState value, resolved once per request from a validated signal, and a single fetch helper that turns it into the right host, token, query parameter and cache policy for your CMS. Components never ask the CMS for drafts directly.

Bash
# .env: two credentials, one per state; only the delivery pair may ever be cached
CMS_DELIVERY_URL=https://cdn.contentful.com/spaces/abc123/environments/master
CMS_DELIVERY_TOKEN=cda_published_only
CMS_PREVIEW_URL=https://preview.contentful.com/spaces/abc123/environments/master
CMS_PREVIEW_TOKEN=cpa_drafts_server_only
PREVIEW_SECRET=shared_with_cms_preview_url_config
PREVIEW_SESSION_MINUTES=30

The draft/publish boundary

A request resolves down one of two never-intersecting paths, decided by the state signal it carries:

The draft and published paths never intersectA request carrying a validated draft signal takes the draft path with a preview token, no-store caching and on-demand fetches; all other requests take the published path with static or incremental rendering and long CDN caching.Incoming requestValid draftsignal?Published graphdelivery tokenSSG / ISRCDN max-ageDraft graphpreview tokenOn-demand renderno-storenoyes
The fork happens once, at the edge or route boundary, and everything after it is configured for exactly one audience.

Keep two content graphs that never intersect at the routing or caching layer. Fetch, transform, and invalidate draft payloads independently of published ones. This prevents cache poisoning — a CDN serving unpublished content to anonymous visitors during edge warm-up or transient build states. Treat draft and published as distinct execution contexts and public endpoints stay immutable while previews get zero-latency freshness.

Query segmentation and routing

State transitions are driven by explicit signals: query parameters, HTTP headers, or signed tokens. GraphQL implementations use a status: draft argument or a dedicated preview schema; REST endpoints use an X-Preview-Mode header or route-level middleware. Normalize these inputs into one state machine before any request fires.

TypeScript
// types/content-state.ts
export type ContentState = 'published' | 'draft';

export interface FetchConfig {
  state: ContentState;
  locale: string;
  previewToken?: string;
}

// lib/content-fetcher.ts
export async function resolveContent(slug: string, config: FetchConfig) {
  const isDraft = config.state === 'draft';
  
  const requestHeaders: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(isDraft && config.previewToken ? { 'Authorization': `Bearer ${config.previewToken}` } : {}),
  };

  const queryParams = new URLSearchParams({
    slug,
    locale: config.locale,
    ...(isDraft ? { status: 'draft', draft: 'true' } : { status: 'published' }),
  });

  // Framework-agnostic fetch with explicit cache directives
  const response = await fetch(`${process.env.CMS_API_URL}/content?${queryParams}`, {
    headers: requestHeaders,
    next: { 
      revalidate: isDraft ? 0 : 300, 
      tags: [`content:${slug}`, isDraft ? 'draft-mode' : 'published'] 
    },
  });

  if (!response.ok) {
    throw new Error(`Content resolution failed: ${response.status}`);
  }
  return response.json();
}

Centralizing this logic makes draft requests bypass long-lived CDN caches by default. Managing draft vs published content states in frontend requires enforcing the boundary in every fetch utility, server component, and API route.

Cache isolation and invalidation

Draft state management is a caching problem. Public content takes aggressive Cache-Control: public, max-age=31536000; draft content takes Cache-Control: no-store or on-demand revalidation tags. Edge networks with granular cache tagging let you purge draft-specific entries without invalidating the published site. When content moves from draft to published, fire targeted purges and trigger ISR — typically via Webhook Triggered Rebuilds — so the frontend reflects the change in seconds, not full deploy cycles.

Core Implementation Pattern: One Helper, Many CMSs

The fetch helper is where the state machine becomes code. It receives the resolved state and returns data; it is the only place that knows which host, token and parameter each CMS needs. A version that supports three platforms behind one signature makes the pattern concrete:

TypeScript
// lib/cms-state.ts
import { draftMode } from "next/headers";

export type ContentState = "published" | "draft";
type Platform = "contentful" | "sanity" | "storyblok";

interface Resolved {
  url: string;
  headers: Record<string, string>;
  cache: RequestInit["cache"];
  next?: { revalidate: number; tags: string[] };
}

export async function currentState(): Promise<ContentState> {
  return (await draftMode()).isEnabled ? "draft" : "published";
}

export function resolveRequest(platform: Platform, path: string, state: ContentState, tags: string[]): Resolved {
  const draft = state === "draft";
  const cachePolicy = draft
    ? { cache: "no-store" as const }
    : { cache: "force-cache" as const, next: { revalidate: 3600, tags } };

  switch (platform) {
    case "contentful": {
      const host = draft ? "preview.contentful.com" : "cdn.contentful.com";
      const token = draft ? process.env.CMS_PREVIEW_TOKEN : process.env.CMS_DELIVERY_TOKEN;
      return { url: `https://${host}/spaces/${process.env.CONTENTFUL_SPACE}/environments/master${path}`, headers: { Authorization: `Bearer ${token}` }, ...cachePolicy };
    }
    case "sanity": {
      const perspective = draft ? "previewDrafts" : "published";
      const sep = path.includes("?") ? "&" : "?";
      const token = draft ? process.env.SANITY_VIEWER_TOKEN : undefined;
      return {
        url: `https://${process.env.SANITY_PROJECT}.api.sanity.io/v2025-02-19/data/query/production${path}${sep}perspective=${perspective}`,
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        ...cachePolicy,
      };
    }
    case "storyblok": {
      const sep = path.includes("?") ? "&" : "?";
      const token = draft ? process.env.STORYBLOK_PREVIEW_TOKEN : process.env.STORYBLOK_PUBLIC_TOKEN;
      return { url: `https://api.storyblok.com/v2/cdn${path}${sep}version=${draft ? "draft" : "published"}&token=${token}`, headers: {}, ...cachePolicy };
    }
  }
}

Two design choices matter more than the details. The cache policy is derived from the state in the same function that chooses the token, so it is impossible to fetch drafts with a caching policy by accident. And the state comes from the framework’s draft mode, which is only enabled by the server after a token check, so no component can decide on its own to fetch drafts.

Caching Drafts Safely

Drafts should never enter shared caches, but that does not mean preview must be slow. Three techniques keep preview fast without risk. Per-request memoization, such as React’s cache() or the framework’s request deduplication, lets several components on one page share a single draft fetch. Short-lived private caching in the browser, using a client cache namespaced by preview state, makes navigation between previewed pages instant. And live preview SDKs push field changes over a socket, so the editor sees updates without any refetch. What remains forbidden is any cache shared between users: the data cache, the ISR cache and the CDN.

Preview Experience for Editors

The technical boundary is only half of draft management; the other half is making the boundary visible to editors. Show a persistent banner on every preview page with the content state, the time the draft was last saved and an exit link that clears draft mode. Make preview links from the CMS open the exact page for the entry, including its locale, by configuring the CMS preview URL with the slug and locale fields. Offer a share link for reviewers outside the CMS, backed by a signed, expiring token rather than the shared preview secret. These small touches prevent the most common support requests: “my change is not showing” from editors who are unknowingly looking at the published site, and “the live site looks wrong” from editors who forgot they were in preview.

Draft State Across Rendering Modes

Where the fork happens depends on how pages are rendered. Static builds cannot render drafts at all without a server, so previews need either a separate preview deployment or on-demand rendering for preview routes. Incremental regeneration can serve drafts through draft mode, which renders on every request and never writes to the cache. Fully dynamic rendering handles drafts naturally but must still keep them out of the data cache and CDN. Client-rendered previews can fetch drafts through an authenticated proxy, provided preview state is part of every client cache key.

How each rendering mode serves draftsFour rendering modes compared by how they render drafts, which caches must be bypassed and the main risk.Rendering modeDraft renderingMust bypassMain riskStatic buildseparate preview deploymentnothing sharedpreview build indexed by search enginesISR + draft modeper request in draft modedata cache, ISR cachefetch ignores draft mode, caches draftServer per requestper requestdata cache, CDNCDN caches draft responseClient-sideproxy with preview flagclient cache namespacepreview key missing in cache key
The risk column is the thing to test; every mode can serve drafts safely when that one boundary holds.

For static site generators such as Astro or Eleventy, a small on-demand preview server or a serverless preview route that renders the same templates with draft data is usually simpler than a second full deployment. The static generator preview guide compares both approaches.

Schema & Content Modeling Considerations

Real editorial workflows have more states than draft and published. Entries can be in review, scheduled, changed since publishing, or archived, and references between entries can point at content in any of those states. The content model and the fetch layer must agree on what each state means for readers.

Editorial states and what readers seeAn entry moves from draft to in review to published, may be scheduled for a future publish, can be changed after publishing, and can be archived; readers only see published versions.DraftIn reviewScheduledpublishAtPublishedChangeddraft over publishedArchivedunpublishedapprovescheduleat timeeditrepublisharchive
A "changed" entry has two versions at once: readers see the published one, previews show the draft on top of it.

The “changed” state deserves attention because it is the common case: most edits happen to entries that are already live. Readers must keep seeing the published version while editors preview the draft layered on top. Contentful’s Preview API returns the latest draft for such entries, Sanity’s previewDrafts perspective overlays drafts on published documents, and Strapi v5 keeps separate draft and published versions of each document. Every preview path should therefore show the complete page as it would look after publishing, including unchanged published entries.

References are the second trap. A published page can reference an entry that is still a draft, and delivery APIs silently drop unresolvable references, so a published component can receive null where the model promised an object. Model required references carefully, validate on publish where the CMS supports it, and make every component tolerate a missing reference. The guide to references to unpublished entries covers the patterns. Scheduled publishing adds a time dimension, covered in scheduled publishing and release windows.

Error Handling & Resilience

Preview failures should fail closed. If the preview token is invalid, expired or missing, the request must fall back to published content or return an authentication error, never to “draft content without checks”. If the CMS Preview API is down, show editors a clear error in the preview banner instead of silently rendering published content, which makes them believe their changes vanished. And if a draft fetch throws during rendering, catch it at the page boundary and render an editor-facing error panel with the entry id and the CMS error message, because editors are the only audience for preview and can act on specifics.

Published paths need the opposite posture: fail soft. A missing reference renders a fallback, a CMS outage serves the last cached page, and nothing about the draft path can affect them. Keeping the two postures separate is easier when the state fork happens once, at the route boundary, as the diagram at the top of this page shows.

Testing & Observability

Draft isolation is a security property, so test it like one. Three automated checks cover most regressions: a request without a valid preview signal never receives draft content, a request with a valid signal never produces a cacheable response, and a draft edit made through the management API appears in preview but not on the public URL. The automated testing for headless integrations topic covers the fixtures and staging setup for such checks.

In production, log every preview session start with the editor’s identity, the entry and the expiry, and alert on preview responses carrying public caching headers. That one alert catches the most damaging class of bug, a draft on its way into a shared cache, before a reader sees it.

Token handling and session persistence

Never gate preview on guessable URLs or persistent query strings. Issue short-lived, cryptographically signed tokens, validated at the edge before routing to draft sources, and store them in HttpOnly, Secure cookies with expiration windows aligned to review cycles. Token-Based Preview Authentication keeps preview mode behind authenticated stakeholders while public traffic stays confined to production graphs.

Framework integration and edge execution

Modern frameworks expose native draft primitives. Next.js App Router uses draftMode() to toggle preview; Nuxt intercepts preview cookies in server middleware. The model is the same everywhere: intercept the request, validate the state signal, fetch from the right endpoint, and render with isolated cache tags. For the caching semantics behind this, see RFC 9111, and for request isolation at the edge, the Cloudflare Workers request-context docs.

Migrating an Existing Site to a Single State Machine

Most sites arrive at draft management gradually: a preview query parameter here, a token check there, and several components that fetch drafts in their own way. Consolidating them is low-risk if done in order. First, inventory every fetch that can return drafts and every place a preview flag is read. Second, introduce the shared helper and route one page type through it, typically the article page, verifying with the two-context leak test. Third, move the preview signal to the framework’s draft mode or a server-set cookie, and make the old query parameter only start the token exchange. Fourth, migrate the remaining page types, finishing with composite pages such as the homepage, which combine the most data sources. Finally, delete the old flags and add a lint rule that forbids importing preview tokens outside the helper module.

The whole migration usually fits in a few days, and the result is easier to reason about than any of the partial solutions it replaces: one place decides the state, one place turns it into requests, and one test proves the boundary holds.

Choosing Where the Fork Lives

The state fork can live in three places, and the choice affects everything downstream. In edge middleware, the fork happens before any rendering, which makes it easy to add caching headers and bypass rules in one place, but middleware cannot run the full data layer. In the route or layout, the fork happens once per request in server code, which is where most frameworks expose draft mode and where the fetch helper runs. In components, the fork is repeated in every data-fetching component, which is exactly the pattern this topic warns against. Most teams combine the first two: middleware adds noindex and bypass headers for preview requests, and the layout reads draft mode and passes nothing further, because the fetch helper reads it again where needed.

Frequently Asked Questions

Should drafts and published content use the same API host?

It depends on the CMS, and it does not matter much to the frontend as long as one helper chooses host, token and parameters together. What matters is that the preview token only exists in server-side code and that preview responses are never cacheable.

How long should a preview session last?

Long enough for a review, typically 30 to 60 minutes, renewed while the editor is active. Short sessions limit the damage of a leaked cookie; very short ones frustrate editors who switch tabs to compare versions.

Can I preview drafts on the production domain?

Yes, with draft mode or an equivalent bypass cookie, and many teams prefer it because it shows exactly what readers will see. A separate preview hostname gives stronger isolation for search engines and caches. Both work; choose one and test its boundary.

How do I stop search engines from indexing previews?

Send X-Robots-Tag: noindex on every preview response, never link to preview URLs from public pages, and serve previews only after a valid token exchange. A separate preview host can additionally be blocked in its own robots.txt.

Do drafts need their own analytics handling?

Yes. Exclude preview sessions from analytics and experiments, or editors’ repeated visits distort page views and test results. The draft-mode cookie is a convenient signal for the analytics snippet to skip tracking.

What about content in the “changed” state during a CDN purge?

Nothing special happens: readers keep seeing the published version, which is still the cached one, until the editor publishes the change. The publish webhook then invalidates caches as for any publish.