Visual Editing

Implementing draft mode

Implement secure draft mode from scratch using @sanity/preview-url-secret and standard Web APIs, with draft mode cookies, validation, and framework-agnostic endpoints.

Draft mode (also called preview mode) lets content editors see unpublished changes in a live frontend preview. When active, your application fetches draft content instead of published content, enables stega encoding for click-to-edit overlays, and disables CDN caching to ensure editors always see the latest changes.

This guide walks through implementing preview mode from scratch using @sanity/preview-url-secret for secure activation and standard Web APIs for the endpoints. The approach works with any server-side framework or runtime.

How draft mode works

The Presentation Tool in Sanity Studio activates preview mode through a secure handshake:

  • The editor opens the Presentation Tool, which renders your frontend in an iframe.
  • The Studio generates a cryptographic secret and stores it as a draft document in your dataset.
  • The Studio navigates the iframe to your enable endpoint, passing the secret as a query parameter.
  • Your endpoint validates the secret against the Sanity API.
  • If valid, your endpoint sets a secure cookie and redirects to the preview page.
  • Subsequent requests check the cookie and serve draft content when present.
Studio                          Frontend
  │                                │
  ├─ Generate secret ──────────►  (stored in dataset)
  │                                │
  ├─ Navigate iframe to ────────► /api/draft-mode/enable
  │   ?sanity-preview-secret=abc    ?sanity-preview-secret=abc
  │   &sanity-preview-pathname=/    &sanity-preview-pathname=/blog
  │   &sanity-preview-perspective=  &sanity-preview-perspective=drafts
  │                                │
  │                                ├─ Validate secret against API
  │                                ├─ Set draft mode cookie
  │                                ├─ Set perspective cookie
  │                                └─ Redirect to /blog
  │                                │
  │                          ◄──── Page renders with draft content

Secrets expire after one hour and are garbage-collected when new secrets are created. Each time the Presentation Tool opens a preview, it generates a fresh secret.

Prerequisites

  • A Sanity project with the Presentation Tool configured (see configuring the Presentation Tool)
  • A server-side runtime that can handle HTTP requests and set cookies
  • A Sanity API token with read access to your dataset (for validating secrets)

Install dependencies

Set up the Sanity client

Create a client configured for secret validation. The client needs a token because preview secrets are stored as draft documents, which require authentication to read.

Important

Build the enable endpoint

The enable endpoint validates the preview secret and activates draft mode by setting a cookie.

Key details about the enable endpoint:

  • validatePreviewUrl queries your dataset for a matching secret document. It checks both per-session secrets (one-hour TTL) and shared access secrets (no TTL) in a single GROQ query. Internally, it overrides your client configuration with perspective: 'raw', useCdn: false, and a pinned API version, so you don't need to configure the client specially for validation.
  • studioPreviewPerspective is the perspective the Studio requested. This is usually drafts, but may be a comma-separated stacked perspective like summer-drop,drafts,published when the editor is previewing a content release. Persisting it as a cookie lets your application use the exact perspective the editor is working in.
  • withoutSecretSearchParams strips the secret-related query parameters from the redirect URL, keeping URLs clean in the browser's address bar and avoiding accidental secret exposure in logs or analytics.
  • SameSite=None is required because the frontend runs inside a Studio iframe (cross-origin context). The Secure flag is also required when using SameSite=None.
  • Max-Age=3600 matches the one-hour secret TTL. The cookie expires at the same time the secret would.

Build the disable endpoint

The disable endpoint clears the perspective cookie:

Setting Max-Age=0 tells the browser to delete the cookie immediately.

Check draft mode in your application

Read the cookies on each request to determine whether to serve draft or published content, and which perspective to use. The sanity-preview-perspective cookie serves double duty: its presence indicates draft mode is active, and its value specifies which perspective to use:

Then use the results to configure the Sanity client for each request:

When draft mode is active, this configuration:

  • Switches to the Studio's requested perspective: this is usually drafts, but may be a stacked perspective array like ['summer-drop', 'drafts', 'published'] when the editor is previewing a content release. Queries resolve content by trying each perspective in priority order.
  • Authenticates with a read token: draft and release content requires an authenticated client. The token is used server-side only and never sent to the browser.
  • Disables the CDN: ensures the editor sees the latest changes without caching delays. Both drafts and array perspectives require useCdn: false.
  • Enables stega encoding: embeds Content Source Map metadata in string values for click-to-edit overlays.

When draft mode is inactive, the client uses the published perspective with CDN caching and no stega encoding, which is the standard production configuration.

Security considerations

Token handling

The SANITY_API_READ_TOKEN is used server-side only to validate preview secrets. It should never be sent to the browser or included in client-side bundles.

If your framework supports environment variable prefixes that expose values to the client (like NEXT_PUBLIC_ in Next.js or VITE_ in Vite), make sure the token variable does not use such a prefix.

Cookie security

The draft mode cookie uses these security attributes:

  • HttpOnly: prevents JavaScript from reading the cookie, protecting against XSS attacks.
  • Secure: the cookie is only sent over HTTPS connections.
  • SameSite=None: required for cross-origin iframe contexts (the Studio and your frontend are on different origins). This is the least restrictive setting, which is why HttpOnly and Secure are important complementary protections.

Secret lifecycle

Preview secrets have built-in protections:

  • Cryptographic randomness: each secret is generated from 16 bytes of WebCrypto randomness, making them impractical to guess.
  • One-hour TTL: secrets expire after 3,600 seconds, limiting the window for replay attacks.
  • Draft document storage: secrets are stored as draft documents in your dataset, so they're only readable by authenticated clients with draft access. They never appear in published content or CDN-cached responses.
  • Garbage collection: expired secrets are cleaned up when new secrets are created.

Shared preview access

The Presentation Tool supports shared preview access, which generates a secret with no TTL. This lets editors share a preview URL with stakeholders who don't have Studio access. Shared access can be toggled on and off in the Presentation Tool UI, and disabling it immediately invalidates the shared secret.

Adapting for your framework

The examples above use the Web API Request and Response objects, which work directly in many runtimes (Deno, Bun, Cloudflare Workers, and Node.js 18+ with a web framework). Here's how to adapt the pattern for specific environments:

Express or Node.js HTTP server

import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from './lib/sanity-client'

// Express route handler
app.get('/api/draft-mode/enable', async (req, res) => {
  const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client,
    fullUrl
  )

  if (!isValid) {
    return res.status(401).send('Invalid or expired preview secret')
  }

  const perspective = studioPreviewPerspective || 'drafts'
  res.cookie(perspectiveCookieName, perspective, {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    maxAge: 3600 * 1000,
    path: '/',
  })

  res.redirect(307, redirectTo || '/')
})

Edge runtimes

When running in edge runtimes (Cloudflare Workers, Vercel Edge Functions), be aware of two runtime-specific behaviors:

  • Edge Runtime delay: validatePreviewUrl automatically adds a 300ms delay in Edge Runtime environments (detected via typeof EdgeRuntime !== 'undefined') to account for eventual consistency. The secret may have been created moments before the validation request arrives.
  • Cloudflare Workers: the cache: 'no-store' fetch option used internally by validatePreviewUrl is automatically disabled when running in Cloudflare Workers (detected via navigator.userAgent). No manual configuration is needed.

Static site generators

Preview mode toggles content per-request, which requires server-side rendering. Pure static site generators (SSG) pre-render pages at build time, so they can't switch between published and draft content for individual requests.

If your production site is statically generated, set up a separate preview deployment that runs in SSR or hybrid mode. Configure the Presentation Tool's previewUrl to point at the SSR deployment, and let your SSG production site continue serving published content. Frameworks like Astro and Next.js support this pattern: production builds remain static, while a preview deployment uses the same codebase with SSR enabled.

Vercel deployment protection

If your frontend uses Vercel's Deployment Protection, the Presentation Tool can bypass it automatically. The preview URL includes x-vercel-protection-bypass and x-vercel-set-bypass-cookie parameters, which validatePreviewUrl forwards to the redirect URL.

No additional configuration is needed in your enable endpoint. The @sanity/preview-url-secret package handles the parameter forwarding internally.

Troubleshooting

"Invalid or expired preview secret" error

  • Check the token: your Sanity client must have a valid API token with read access. validatePreviewUrl throws a TypeError if the client doesn't have a token configured. Without a token, the client can't query draft documents where secrets are stored.
  • Check the clock: secrets expire after one hour based on the _updatedAt timestamp. If your server's clock is significantly skewed, validation may fail.
  • Check the dataset: the client must be configured with the same dataset that the Studio writes secrets to.
  • Debug with Vision: you can query secrets directly in the Studio's Vision tool with *[_type == "sanity.previewUrlSecret"] to verify they exist. Secrets are stored as draft documents with drafts.{uuid} IDs.

Draft mode activates but content doesn't change

  • Check the perspective: verify that your client uses the perspective from the sanity-preview-perspective cookie when draft mode is active. The published perspective never returns draft content. If the cookie contains a comma-separated value (for content releases), make sure you split it into an array before passing it to withConfig().
  • Check useCdn: the drafts perspective and array perspectives both require useCdn: false. CDN-cached responses only contain published content.

Cookie not persisting across requests

  • Check SameSite and Secure: in iframe contexts, SameSite=None and Secure are both required. Without them, the browser may silently drop the cookie.
  • Check HTTPS: the Secure flag means the cookie is only sent over HTTPS. If you're developing locally over HTTP, you may need to use localhost (which browsers treat as a secure context) or set up a local HTTPS certificate.

Preview works locally but not in production

  • Check CORS: your Sanity project must allow requests from your production frontend origin.
  • Check the Presentation Tool's allowOrigins: your production URL must be in the allowed list.
  • Check environment variables: verify that SANITY_API_READ_TOKEN is set in your production environment.

Next steps

Was this page helpful?