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
npm install @sanity/preview-url-secret @sanity/client
pnpm add @sanity/preview-url-secret @sanity/client
yarn add @sanity/preview-url-secret @sanity/client
bun add @sanity/preview-url-secret @sanity/client
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.
import { createClient } from '@sanity/client'
export const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-12-01',
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
})Important
The token must have read access to draft and version documents. Never expose this token to the browser. It should only be used in server-side code.
Build the enable endpoint
The enable endpoint validates the preview secret and activates draft mode by setting a cookie.
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { client } from '../../lib/sanity-client'
export async function handleEnableDraftMode(request: Request): Promise<Response> {
// Validate the secret against the Sanity API.
// This checks that the secret exists as a draft document
// and was created within the last hour.
// It also checks shared access secrets (no TTL) in the same query,
// so this endpoint automatically supports both regular preview
// and shared preview access without additional code.
const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
client,
request.url
)
if (!isValid) {
return new Response('Invalid or expired preview secret', { status: 401 })
}
// Build the redirect URL, stripping secret params for clean URLs
const cleanRedirect = redirectTo
? withoutSecretSearchParams(new URL(redirectTo, request.url)).pathname
: '/'
// Set the perspective cookie. This serves as both the draft mode indicator
// and the perspective value. If the Studio didn't send a perspective,
// default to 'drafts'.
const perspective = studioPreviewPerspective || 'drafts'
const headers = new Headers()
headers.append(
'Set-Cookie',
[
`${perspectiveCookieName}=${perspective}`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=None',
'Max-Age=3600',
].join('; ')
)
headers.set('Location', cleanRedirect)
return new Response(null, { status: 307, headers })
}Key details about the enable endpoint:
validatePreviewUrlqueries 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 withperspective: 'raw',useCdn: false, and a pinned API version, so you don't need to configure the client specially for validation.studioPreviewPerspectiveis the perspective the Studio requested. This is usuallydrafts, but may be a comma-separated stacked perspective likesummer-drop,drafts,publishedwhen the editor is previewing a content release. Persisting it as a cookie lets your application use the exact perspective the editor is working in.withoutSecretSearchParamsstrips 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=Noneis required because the frontend runs inside a Studio iframe (cross-origin context). TheSecureflag is also required when usingSameSite=None.Max-Age=3600matches 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:
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
export async function handleDisableDraftMode(request: Request): Promise<Response> {
const headers = new Headers()
headers.append(
'Set-Cookie',
[
`${perspectiveCookieName}=`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=None',
'Max-Age=0',
].join('; ')
)
headers.set('Location', '/')
return new Response(null, { status: 307, headers })
}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:
import { validateApiPerspective, type ClientPerspective } from '@sanity/client'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
export function isDraftMode(request: Request): boolean {
const cookieHeader = request.headers.get('Cookie') || ''
return cookieHeader.includes(`${perspectiveCookieName}=`)
}
export function getPreviewPerspective(request: Request): ClientPerspective {
const cookieHeader = request.headers.get('Cookie') || ''
const regex = new RegExp(`${perspectiveCookieName}=([^;]+)`)
const match = cookieHeader.match(regex)
if (!match) return 'drafts'
// The cookie value may be a comma-separated stacked perspective
// (for example, "summer-drop,drafts,published" for a content release)
const value = match[1]
const perspective = value.includes(',') ? value.split(',') : value
try {
validateApiPerspective(perspective)
return perspective === 'raw' ? 'drafts' : perspective
} catch {
return 'drafts'
}
}Then use the results to configure the Sanity client for each request:
import { client } from './sanity-client'
import { isDraftMode, getPreviewPerspective } from './draft-mode'
export async function fetchContent(
request: Request,
query: string,
params?: Record<string, unknown>
) {
const preview = isDraftMode(request)
const perspective = preview ? getPreviewPerspective(request) : 'published'
const configuredClient = client.withConfig({
perspective,
useCdn: !preview,
stega: { enabled: preview },
// Token required server-side to fetch draft/release content
...(preview && { token: process.env.SANITY_API_READ_TOKEN }),
})
return configuredClient.fetch(query, params)
}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
draftsand array perspectives requireuseCdn: 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 whyHttpOnlyandSecureare 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:
validatePreviewUrlautomatically adds a 300ms delay in Edge Runtime environments (detected viatypeof 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 byvalidatePreviewUrlis automatically disabled when running in Cloudflare Workers (detected vianavigator.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.
validatePreviewUrlthrows aTypeErrorif 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
_updatedAttimestamp. 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 withdrafts.{uuid}IDs.
Draft mode activates but content doesn't change
- Check the perspective: verify that your client uses the perspective from the
sanity-preview-perspectivecookie when draft mode is active. Thepublishedperspective 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 towithConfig(). - Check
useCdn: thedraftsperspective and array perspectives both requireuseCdn: false. CDN-cached responses only contain published content.
Cookie not persisting across requests
- Check
SameSiteandSecure: in iframe contexts,SameSite=NoneandSecureare both required. Without them, the browser may silently drop the cookie. - Check HTTPS: the
Secureflag means the cookie is only sent over HTTPS. If you're developing locally over HTTP, you may need to uselocalhost(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_TOKENis set in your production environment.
Next steps
- Architecture overview: understand how preview mode fits into the broader visual editing system.
- Setting up the Sanity client for visual editing: configure stega encoding, perspectives, and Content Source Maps.
- Enabling overlays and click-to-edit: add click-to-edit functionality to your preview.
- Configuring the Presentation Tool: set up the Studio plugin that triggers preview mode.