# Implementing draft mode

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:

1. The editor opens the Presentation Tool, which renders your frontend in an iframe.
2. The Studio generates a cryptographic secret and stores it as a draft document in your dataset.
3. The Studio navigates the iframe to your enable endpoint, passing the secret as a query parameter.
4. Your endpoint validates the secret against the Sanity API.
5. If valid, your endpoint sets a secure cookie and redirects to the preview page.
6. Subsequent requests check the cookie and serve draft content when present.

```text
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](https://www.sanity.io/docs/visual-editing/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

```sh
npm install @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.

**lib/sanity-client.ts**

```typescript
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,
})
```

> [!WARNING]
> 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.

**api/draft-mode/enable.ts**

```typescript
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:

- **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:

**api/draft-mode/disable.ts**

```typescript
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:

**lib/draft-mode.ts**

```typescript
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:

**lib/fetch-content.ts**

```typescript
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 `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

```typescript
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

- **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.

