Visual Editing

Presentation Tool & draft mode

Sanity's Presentation Tool iframes the running website and lets an editor click any element on the page to jump straight to the field that produced it — but that only works because the website, on every SSR request, decides whether the caller is a trusted editor, fetches with a stega-enabled client when they are, and lets an overlay bind those invisible source tags back to the Studio.

This page covers the full handshake (enable → cookie → middleware → SSR → overlay), the two cookies that drive it (one public, one signed), why the second cookie has to exist, the stega-enabled preview client, and the one footgun that makes every Sanity string field dangerous in draft mode. The Studio-side wiring (resolvers, previewUrl, CORS) lives in Studio & singletons; how routes pick their client lives in Querying content (GROQ); the static-vs-SSR split lives in Two-build split.


How Presentation iframes the site

The hosted Studio (whitetreedigital.sanity.studio) runs Sanity's Presentation Tool: a split screen with the Studio document form on the left and a live iframe of the website on the right. The iframe URL is env-driven (SANITY_STUDIO_PREVIEW_URL), and it must point at an SSR deployment — the staging Worker, not the static public site — because only an SSR build can read a request-time cookie and emit stega. The public whitetreedigital.com build is fully static for content pages and ships no Visual Editing overlay at all.

Clicking an element in the iframe jumps to its field; editing a field streams back into the iframe. Both directions are powered by stega: invisible Unicode tag characters that the preview client bakes into every string field, encoding a pointer back to the exact document + path in the Studio.

output: 'server' is required

The website's astro.config.mjs sets output: 'server' (for the staging/SSR build) precisely so draft preview can read the perspective cookie at request time. If you prerender a content route that may need draft preview, the cookie read is gone and the route can never show drafts. Don't add export const prerender = true to content routes. (The static public build is a separate BUILD_TARGET that deliberately strips all of this — see Two-build split.)


The handshake

The end-to-end flow from "editor opens Presentation" to "click-to-edit works":

Studio  ──GET /api/draft-mode/enable?sanity-preview-secret=…──▶  website
website ── validates the secret, sets `sanity-preview-perspective` cookie
          + signed httpOnly `sanity-preview-auth` companion cookie, 307 ──▶  /
middleware ── verifies the signature; a perspective cookie without a valid
             companion is DELETED before any route reads it
SSR route ── detects cookie → fetches with the stega-enabled preview client
            → returns stega-encoded HTML
overlay  ── <VisualEditing client:only="react" /> binds to the stega tags
           → click-to-edit works

1. Enable

src/pages/api/draft-mode/enable.ts (export const prerender = false) is the entry point. It reads the read token from the Cloudflare Workers runtime env, validates the preview secret Sanity appended to the URL, and on success sets two cookies before redirecting to the target path:

const clientWithToken = sanity.withConfig({token})
const {isValid, redirectTo = '/', studioPreviewPerspective} =
  await validatePreviewUrl(clientWithToken, request.url)

if (!isValid) {
  return new Response('Invalid preview secret', {status: 401})
}

cookies.set(perspectiveCookieName, studioPreviewPerspective ?? 'drafts', {
  httpOnly: false,
  sameSite: 'none',
  secure: true,
  path: '/',
})
// Signed companion cookie — middleware rejects a perspective cookie that
// arrives without a valid signature (see src/lib/draft-auth.ts).
cookies.set(draftAuthCookieName, await createDraftAuthValue(token), {
  httpOnly: true,
  sameSite: 'none',
  secure: true,
  path: '/',
})
return redirect(redirectTo, 307)

The token is read via import {env} from 'cloudflare:workers' (with an import.meta.env fallback for local dev). SANITY_API_READ_TOKEN lives in .dev.vars locally and as an encrypted Secret in the CF Workers dashboard in production — never in .env, never committed.

The two cookies have different visibilities on purpose:

CookieNamehttpOnlyPurpose
Perspectivesanity-preview-perspectivefalseSelects the draft perspective; read by the overlay in the browser
Companionsanity-preview-authtrueHMAC proof that enable.ts minted the session; invisible to JS

sameSite: 'none' + secure: true are mandatory: the cookies have to survive the cross-site context of Presentation's iframe.

2. The middleware gate

src/middleware.ts runs validateDraftCookie first on every request. If a perspective cookie is present, it verifies the companion signature; if the companion is missing, invalid, or expired, both cookies are deleted before any route reads them — the request is silently demoted to published.

const validateDraftCookie = defineMiddleware(async (context, next) => {
  if (context.cookies.has(perspectiveCookieName)) {
    const token = env.SANITY_API_READ_TOKEN ?? import.meta.env.SANITY_API_READ_TOKEN;
    const auth = context.cookies.get(draftAuthCookieName)?.value;
    const valid = token && auth ? await verifyDraftAuthValue(auth, token) : false;
    if (!valid) {
      context.cookies.delete(perspectiveCookieName, {path: '/'});
      context.cookies.delete(draftAuthCookieName, {path: '/'});
    }
  }
  return next();
});

The second middleware in the chain, edgeCache, serves anonymous published responses from the Cloudflare Cache API (300s TTL). It calls next() immediately when isDraftMode() is true, so editors always bypass the cache and see live drafts — but that bypass is exactly why the gate above has to run first.

3. SSR with the preview client

Once the gate has confirmed a valid draft session, route frontmatter picks the draft client. src/lib/sanity-draft.ts is the per-request picker: a present cookie selects the stega preview client with the runtime token attached; otherwise the published client.

export async function getSanityClient(cookies: AstroCookies) {
  if (!isDraftMode(cookies)) return sanity
  const {env} = await import('cloudflare:workers')
  const token = env.SANITY_API_READ_TOKEN ?? import.meta.env.SANITY_API_READ_TOKEN
  return token ? sanityPreview.withConfig({token}) : sanityPreview
}

The token is attached per request via .withConfig({token}), never baked into the module-level sanityPreview — the token is a runtime secret on Cloudflare, not a build-time value. Routes always go through getRouteClient(Astro.cookies), which short-circuits to the published client in the static build (so its prerendered graph stays token-free and never reads cookies) and falls through to the cookie-aware picker in the SSR build. The full client-selection mechanics are in Querying content (GROQ).

4. The overlay

src/layouts/Layout.astro computes draftMode once and renders the overlay and an "Exit preview" link only when it's true:

const draftMode = import.meta.env.BUILD_TARGET !== 'static' && isDraftMode(Astro.cookies);
{draftMode && (
  <>
    <VisualEditing client:only="react" />
    <a href="/api/draft-mode/disable" class="...">Exit preview</a>
  </>
)}

src/components/VisualEditing.tsx is a thin wrapper around @sanity/visual-editing/react. Its only customization is a refresh callback that hard-reloads the page on edit — Astro has no client-side router to patch, so a full reload is the simplest correct response:

<BaseVisualEditing
  portal={true}
  refresh={async () => {
    window.location.reload()
  }}
/>

client:only="react" keeps the overlay out of the SSR HTML entirely; it only exists in the browser, where the stega tags are.

Disable

src/pages/api/draft-mode/disable.ts (the "Exit preview" target) deletes both cookies and redirects home. Deleting the companion alone would leave a dangling perspective cookie; deleting the perspective alone would leave the gate guarding nothing — so they're cleared as a pair, mirroring how they were set:

cookies.delete(perspectiveCookieName, {path: '/'})
cookies.delete(draftAuthCookieName, {path: '/'})
return redirect('/', 307)

The perspective cookie alone cannot be trusted. Its name (sanity-preview-perspective) is a public constant exported from @sanity/preview-url-secret/constants, and enable.ts deliberately sets it httpOnly: false so the in-browser overlay can read it. Presence therefore proves nothing — anyone could hand-set that cookie. A forged one would otherwise get two things it must not have:

  1. The token-attached draft client — meaning unpublished drafts become readable, and the CDN is bypassed.
  2. The edge-cache bypassisDraftMode() is true, so every request skips the Cache API and hits the Worker + two Sanity round-trips.

The fix is a second, httpOnly cookie that only enable.ts can mint. src/lib/draft-auth.ts signs it with an HMAC. Crucially, the HMAC key is SANITY_API_READ_TOKEN — already a runtime secret on both sides of the handshake, so there's no new secret to provision.

The cookie value is a compact ${expiry}.${signature} pair with an 8-hour lifetime:

/** `${expiryEpochMs}.${hmac(expiryEpochMs)}` */
export async function createDraftAuthValue(secret: string): Promise<string> {
  const exp = Date.now() + MAX_AGE_MS;
  const key = await importKey(secret);
  const sig = await crypto.subtle.sign('HMAC', key, enc.encode(String(exp)));
  return `${exp}.${toBase64Url(sig)}`;
}

Verification (in the middleware) checks the expiry first, then verifies the signature over the expiry string using crypto.subtle.verify. It uses the Web Crypto API (crypto.subtle), which is native to the Workers runtime — no Node crypto, no extra dependency.

Don't relax either cookie's flags

The whole defense rests on the asymmetry: the perspective cookie is forgeable-by-design (public name, JS-readable), and the companion is unforgeable (signed, httpOnly). If you make the perspective cookie httpOnly, the overlay breaks. If you drop the companion or make it JS-readable, a forged perspective cookie regains a token-attached draft client and bypasses the cache. Keep one public and one signed.


The stega-enabled preview client

Click-to-edit needs source tags in the HTML, and those come from one config flag. src/lib/sanity.ts exports two clients: sanity (published, useCdn: true) and sanityPreview (drafts, stega on):

export const sanityPreview = createClient({
  ...baseConfig,
  useCdn: false,
  perspective: 'drafts',
  stega: {
    enabled: true,
    studioUrl,
  },
})

stega.enabled: true is load-bearing. Without it the client returns plain JSON, the rendered HTML carries no source tags, and the overlay has nothing to bind to — the iframe loads but clicking does nothing. studioUrl (PUBLIC_SANITY_STUDIO_URL, e.g. https://whitetreedigital.sanity.studio/production) is where each stega pointer resolves when an editor clicks. Note the preview client has useCdn: false — drafts must never be served from the CDN.


Stega breaks string equality (read this)

This is the single most important footgun in the codebase, and it is invisible: code that works perfectly in production silently misbehaves only in draft preview.

In draft mode, the sanityPreview client encodes every string field with invisible Unicode tag characters pointing back to the Studio. Those characters are invisible but not zero-length — they break exact string comparisons, switch cases, regex matches, JSON parsing of stringified values, and object/array index keys.

stegaClean() before any logic on a Sanity string

Any time a Sanity string field controls JS logic — an equality check, a switch, a regex, JSON.parse, an object/array index key, or an attribute value consumed by another tool — call stegaClean() from @sanity/client/stega on the value first. Without it, the code works in production (no stega) and silently fails only in draft preview.

import {stegaClean} from '@sanity/client/stega';

// ❌ Silent failure in draft preview — `variant` carries invisible chars
const isToc = variant === 'tocSidebar';

// ✅ Equality survives stega
const isToc = stegaClean(variant) === 'tocSidebar';

The canonical real-world crash: WorkCard used colorway directly as palettes[colorway]. In draft the invisible chars made the lookup miss, palette came back undefined, and palette.bg threw — which, in Astro SSR, blanks the whole page. The fix cleans and falls back: palettes[stegaClean(colorway)] ?? palettes.forest.

When you do NOT need to clean: rendering a plain string ({post.title}, <p>{section.heading}</p>) is fine and indeed required — those invisible chars are exactly what the overlay binds click-to-edit to. Only clean when the value participates in logic. Booleans carry no stega, so flags like animate / hideSection need no cleaning.

Treat stegaClean() as the default whenever you add a new 'centered' | 'sidebar' | … style field. Across the codebase this guard appears on variant fields, layout toggles (imagePosition, scoresPosition, orientation, revealTrigger), embed-code fields passed to set:html, and any data-* attribute value that feeds a query string or downstream parser.

  • Color values never use the preview client. Layout.astro fetches the globalStyles theme tokens with the published sanity client even in draft mode — stega's zero-width chars would corrupt CSS color strings (e.g. #1F5D3A), so global theming reads published only. (Same reason PSI scores read the published client.) See Brand tokens & theme.
  • The hero headline animation is off in draft by default. SplitText would shred the headline's stega payload across spans, crashing the Visual Editing decoder with thousands of "Failed to decode stega" errors. Both heroes keep the headline static in draft unless you append ?heroanim (which stegaClean()s the headline first, trading click-to-edit on that one field for the animation). See Hero headline animation.

Gotchas & operational notes

  • Presentation points at staging, not public. The public site is static and isn't a preview target. If the staging URL changes, update SANITY_STUDIO_PREVIEW_URL in studio/.env.production and re-run npx sanity deploy. Details in Studio & singletons.
  • CORS with credentials. Every preview origin must be a credentialed Sanity CORS origin (npx sanity cors add <url> --credentials). The cookies are cross-site, so credentialed CORS is mandatory.
  • Staging isn't behind Cloudflare Access. Access's login interstitial sends X-Frame-Options: DENY, which would break the iframe. Staging is instead gated by noindex + the HMAC draft-cookie gate documented above. See Two-build split.
  • The Cache API is active on *.workers.dev. Despite older docs, edge caching runs in production (verified June 2026). Anonymous published views are cached for 300s; draft requests bypass entirely.

Where this lives

ConcernFile
Enable endpoint (set cookies, validate secret)website/src/pages/api/draft-mode/enable.ts
Disable endpoint (clear both cookies)website/src/pages/api/draft-mode/disable.ts
Signed companion cookie (mint + verify, HMAC)website/src/lib/draft-auth.ts
Draft-cookie gate + edge cachewebsite/src/middleware.ts
Per-request client pickerwebsite/src/lib/sanity-draft.ts
Published + stega preview clientswebsite/src/lib/sanity.ts
Visual Editing overlay wrapperwebsite/src/components/VisualEditing.tsx
Overlay + "Exit preview" render gatewebsite/src/layouts/Layout.astro
Presentation Tool config, resolvers, CORSstudio/sanity.config.ts, studio/CLAUDE.md
Draft-mode + stega narrativewebsite/CLAUDE.md ("Draft mode + Presentation Tool", "Stega + string equality")
Previous
Adding a section