Visual Editing

Visual editing architecture overview

Understand how Sanity's visual editing works across six architectural layers: content source maps, stega encoding, overlays, live updates, preview mode, and the Presentation Tool.

Sanity's visual editing system lets content editors click on elements in a live preview to jump directly to the corresponding field in Sanity Studio. It also supports real-time content updates, so editors see changes reflected in the preview as they type.

This guide explains how the system works at an architectural level, without assuming any specific frontend framework. Whether you're integrating with an existing framework library like next-sanity or building a custom integration from scratch, understanding these layers will help you make informed decisions.

What you'll learn

  • The layered architecture of visual editing and how each layer contributes.
  • How content flows from the Content Lake to a live preview.
  • The role of Content Source Maps and stega encoding in click-to-edit.
  • How the Presentation Tool communicates with your frontend.
  • What framework libraries abstract away, and what you need to build yourself.

Architecture layers

Visual editing is built from seven distinct layers. Each layer has a specific responsibility, and they compose together to create the full experience:

┌─────────────────────────────────────────────────────┐
│  7. Framework libraries                             │
│     next-sanity, @nuxtjs/sanity, @sanity/svelte-loader │
├─────────────────────────────────────────────────────┤
│  6. Presentation Tool         (sanity/presentation) │
│     Studio plugin: iframe preview, document routing │
├─────────────────────────────────────────────────────┤
│  5. Preview authentication  (preview-url-secret)    │
│     Secure draft mode activation                    │
├─────────────────────────────────────────────────────┤
│  4. Data loading              (@sanity/core-loader) │
│     Perspective switching, live updates             │
├─────────────────────────────────────────────────────┤
│  3. Overlays          (@sanity/visual-editing)      │
│     DOM scanning, click-to-edit UI                  │
├─────────────────────────────────────────────────────┤
│  2. Communication               (@sanity/comlink)   │
│     postMessage protocol between Studio and iframe  │
├─────────────────────────────────────────────────────┤
│  1. Foundation                  (@sanity/client)    │
│     Stega encoding, Content Source Maps, GROQ       │
└─────────────────────────────────────────────────────┘

Layer 1: foundation (@sanity/client)

The Sanity client is the base layer. It handles GROQ queries, Content Source Maps, and stega encoding.

When you enable stega on the client, it requests Content Source Maps from the Content Lake and encodes source metadata into string values as invisible zero-width Unicode characters. This means every rendered string carries information about which document and field it came from, without any visible change to the content.

import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2025-12-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'https://your-studio.sanity.studio',
  },
})

The client also supports perspectives, which control whether queries return published content, draft content, or content from a specific release. Perspectives can be a single value like 'published' or 'drafts', or a priority-ordered array like ['summer-drop', 'drafts', 'published'] that resolves content from the first matching perspective. This is the mechanism that powers preview mode and content releases.

Layer 2: communication (@sanity/comlink)

Comlink provides a typed, bidirectional messaging protocol over the browser's postMessage API. It connects the Sanity Studio (parent window) with your frontend (child iframe) using HTTP-like semantics.

Key features of the protocol:

  • Connection handshaking: automatic connection establishment with state tracking (idle, handshaking, connected, disconnected).
  • Heartbeat monitoring: detects when the connection drops.
  • Origin validation: restricts which origins can communicate, preventing unauthorized access.
  • Named endpoints: routes messages between specific channels.

You don't interact with Comlink directly in most integrations. The @sanity/visual-editing package and the Presentation Tool use it internally to coordinate navigation, content refreshes, and click-to-edit events.

Layer 3: overlays (@sanity/visual-editing)

This package scans the DOM for stega-encoded strings, decodes the embedded Content Source Maps, and draws transparent click-to-edit overlays on top of content elements. When an editor clicks an overlay, it sends a message through Comlink to the Studio, which navigates to the corresponding document and field.

import { enableVisualEditing } from '@sanity/visual-editing'

const disable = enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      // Notify the Studio when the frontend URL changes
      const handler = () => navigate({ type: 'pop', url: location.href })
      addEventListener('popstate', handler)
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      // Handle navigation requests from the Studio
      if (update.type === 'push') history.pushState(null, '', update.url)
      if (update.type === 'replace') history.replaceState(null, '', update.url)
    },
  },
  refresh: async (payload) => {
    // Handle content refresh requests.
    // Return Promise<void> for async operations, or false to skip.
    if (payload.source === 'mutation') {
      // A document was edited in the Studio. Re-fetch content.
      // See "Real-time content updates" for full implementation.
    }
  },
})

The overlay system also supports manual data attributes for elements where stega encoding isn't available. Since stega only works on strings, you need data attributes for non-string content like images, numbers, and booleans. Use createDataAttribute() or set attributes directly:

import { createDataAttribute } from '@sanity/visual-editing'

// Using createDataAttribute for structured annotations
const attr = createDataAttribute({
  id: 'post-1',
  type: 'post',
  path: 'mainImage',
  baseUrl: 'https://your-studio.sanity.studio',
})

const img = document.querySelector('.hero-image')
img.setAttribute('data-sanity', attr.toString())

// Or set the edit target attribute directly
const element = document.querySelector('.hero-title')
element.dataset.sanityEditTarget = ''

Layer 4: data loading (@sanity/core-loader)

The core loader is a framework-agnostic foundation for data fetching with visual editing support. It handles perspective switching (including stacked perspectives for content releases), stega encoding based on preview state, and live content subscriptions.

import { createQueryStore } from '@sanity/core-loader'

const queryStore = createQueryStore({ client })

// Create a reactive store for a specific query
const postStore = queryStore.createFetcherStore(
  '*[_type == "post" && slug.current == $slug][0]',
  { slug: 'my-post' }
)

// Subscribe to updates (the store fetches data when subscribed)
postStore.subscribe((state) => {
  // state.data contains the query result
  // state.sourceMap contains the Content Source Map
})

Framework-specific loaders build on top of this:

  • @sanity/react-loader for React-based frameworks
  • @sanity/svelte-loader for SvelteKit
  • @nuxtjs/sanity for Nuxt

If you're building a custom integration, you can use @sanity/core-loader directly or work with @sanity/client at a lower level.

Layer 5: preview authentication (@sanity/preview-url-secret)

This package handles secure activation of draft mode. When the Presentation Tool opens your frontend in an iframe, it calls an enable endpoint on your server. The preview-url-secret package validates that the request is legitimate by checking a shared secret against the Sanity API.

The typical flow:

  • The Presentation Tool sends a request to your /api/draft-mode/enable endpoint with a secret token.
  • Your server validates the token using @sanity/preview-url-secret.
  • If valid, your server sets a cookie or session flag to enable draft mode.
  • Subsequent requests serve draft content with stega encoding enabled.

Layer 6: Presentation Tool (sanity/presentation)

The Presentation Tool is a Studio plugin that renders your frontend in an iframe. It manages the preview lifecycle: activating draft mode, synchronizing navigation between the Studio and your frontend, and displaying document location information.

It uses two types of resolvers to connect documents with frontend routes:

  • mainDocuments: maps URL patterns to Sanity documents (for example, /posts/:slug resolves to a post document).
  • locations: maps document types to the frontend URLs where they appear (for example, a post document appears at /posts/my-post and /posts).

Layer 7: framework libraries

Libraries like next-sanity, @nuxtjs/sanity, and @sanity/svelte-loader wrap the lower layers into framework-idiomatic APIs. They handle the framework-specific parts that differ between environments:

  • Preview mode toggling: Next.js Draft Mode, SvelteKit hooks, Nuxt middleware.
  • Data fetching patterns: server components, composables, loaders.
  • Caching strategies: Next.js cache tags, SvelteKit cache control.
  • Reactivity models: React hooks, Svelte stores, Vue reactivity.

The underlying visual editing primitives are the same across all frameworks. The differences are in how each framework handles server-side rendering, routing, and state management.

Framework support matrix

FrameworkSupport levelNotes
Next.js (App Router)FullPage building experience via `defineLive`. Use `next-sanity`.
Next.js (Pages Router)FullLoaders pattern. Use `next-sanity`.
RemixFullLoaders pattern.
NuxtFullLoaders pattern. Use `@nuxtjs/sanity`.
SvelteKitFullLoaders pattern. Use `@sanity/svelte-loader`.
AstroBasicServer-side support via SSR/hybrid mode. Use `@sanity/astro`.
Vanilla TypeScript or any frameworkBasicDirect use of `@sanity/visual-editing`, `@sanity/core-loader`, and `@sanity/client`. See the [complete integration example](/docs/07-complete-example.md).

Frameworks marked Full include built-in helpers for draft mode, server-side fetching, and live updates. Frameworks marked Basic require server-side rendering and direct integration with the underlying packages.

Key concepts

Content Source Maps

Content Source Maps are metadata returned by the Content Lake that map every value in a query result back to its source document and field. They follow an open standard and are the foundation for click-to-edit functionality.

Request them by adding resultSourceMap=true to your GROQ queries (the Sanity client does this automatically when stega is enabled):

{
  "result": [
    { "_id": "post-1", "title": "Hello World", "author": { "name": "Jane" } }
  ],
  "resultSourceMap": {
    "documents": [
      { "_id": "post-1", "_type": "post" },
      { "_id": "author-jane", "_type": "author" }
    ],
    "paths": [
      "$['title']",
      "$['author']['name']"
    ],
    "mappings": {
      "$['title']": {
        "type": "value",
        "source": { "type": "documentValue", "document": 0, "path": 0 }
      },
      "$['author']['name']": {
        "type": "value",
        "source": { "type": "documentValue", "document": 1, "path": 1 }
      }
    }
  }
}

The mappings object connects each value in the result to its source document and field. In this example, $['title'] maps to document index 0 (post-1) at path index 0 ($['title']), while $['author']['name'] maps to a different document (author-jane). The keys use JSONPath notation matching the query result structure.

Stega encoding

Stega encoding embeds Content Source Map data as invisible characters in string values. It uses four zero-width Unicode characters as a base-4 encoding scheme:

CharacterUnicodeName
`​`U+200BZero Width Space
`‌`U+200CZero Width Non-Joiner
`‍`U+200DZero Width Joiner
``U+FEFFByte Order Mark

A string like "Oxford Shoes" looks identical after encoding, but contains an appended sequence of invisible characters that encode the document ID, field path, and Studio URL.

Automatic exclusions: stega encoding skips values that would break if modified, including values at paths where the last segment starts with _ (like _id and _type), URLs, ISO dates, non-string values, and slug.current paths. The client also maintains a 39-name denylist of common non-display field names. See setting up the Sanity client for the full filtering rules.

Cleaning encoded strings: use stegaClean() before using stega-encoded values in non-display contexts like URL construction, string comparisons, or date parsing:

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

const slug = stegaClean(post.slug.current)
const url = `/posts/${slug}`

Perspectives

Perspectives control which version of documents your queries return:

  • published (default): returns only published documents. Results are CDN-cached and suitable for production.
  • drafts: treats all drafts as if they were published. Results are not cached, ensuring editors always see the latest changes. Requires useCdn: false.
  • raw: returns documents with their actual _id prefixes intact (for example, drafts. prefixed documents appear alongside published versions). For authenticated requests only.
  • Stacked perspectives (array): a priority-ordered list like ['summer-drop', 'drafts', 'published']. The system resolves content by trying each perspective in order: first the release version, then drafts, then published. This is how content releases work: editors can preview how content will look when a specific release is published. Array perspectives require useCdn: false.

Switch perspectives using client.withConfig():

// Production: published content, CDN-cached
const publishedClient = client.withConfig({
  perspective: 'published',
  useCdn: true,
})

// Preview: draft content, always fresh
const previewClient = client.withConfig({
  perspective: 'drafts',
  useCdn: false,
})

// Content release: preview a specific release with drafts and published as fallbacks
const releaseClient = client.withConfig({
  perspective: ['summer-drop', 'drafts', 'published'],
  useCdn: false, // Required for array perspectives
})

End-to-end flow

Here's how all the layers work together when an editor uses visual editing:

1. Editor opens the Presentation Tool in Sanity Studio
   │
2. Studio loads your frontend in an iframe
   │
3. Studio calls /api/draft-mode/enable on your server
   │  └─ Your server validates the request (preview-url-secret)
   │  └─ Sets a draft mode cookie
   │
4. Frontend re-renders in draft mode
   │  └─ Queries use the "drafts" perspective
   │  └─ Stega encoding is active
   │
5. Content renders with invisible source metadata
   │  └─ enableVisualEditing() scans the DOM
   │  └─ Transparent overlays appear on content elements
   │
6. Editor clicks on a content element
   │  └─ Overlay decodes the stega data
   │  └─ Sends document ID and field path to Studio via Comlink
   │
7. Studio navigates to the document and focuses the field
   │
8. Editor changes the field value
   │  └─ Mutation saved to the Content Lake
   │  └─ Live Content API emits a sync tag
   │
9. Frontend receives the update
   │  └─ Re-fetches affected content
   │  └─ Page updates with the new value

What framework libraries handle for you

If you use a framework library like next-sanity, it handles most of the integration work:

ConcernFramework libraryCustom integration
Stega encodingAutomatic via client configAutomatic via client config
Perspective switchingAutomatic based on preview stateManual via `client.withConfig()`
Draft mode toggleProvided (for example, `defineEnableDraftMode`)Build your own enable/disable endpoints
Overlay renderingProvided (for example, `<VisualEditing />`)Call `enableVisualEditing()` directly
Live updatesProvided (for example, `<SanityLive />`)Subscribe to the Live Content API
Caching and revalidationFramework-optimizedImplement your own strategy
Router integrationAutomaticWire up `history` callbacks manually

The core primitives (stega encoding, Content Source Maps, Comlink, overlays) are the same regardless of framework. The differences are in how preview mode is toggled, how data is fetched, and how the UI reacts to changes.

Packages at a glance

PackagenpmRole
`@sanity/client``@sanity/client`GROQ queries, stega encoding, Content Source Maps
`@sanity/comlink``@sanity/comlink`postMessage protocol between Studio and iframe
`@sanity/visual-editing``@sanity/visual-editing`DOM overlays, click-to-edit, `enableVisualEditing()`
`@sanity/core-loader``@sanity/core-loader`Framework-agnostic data loading with live updates
`@sanity/preview-url-secret``@sanity/preview-url-secret`Secure draft mode activation and validation
`sanity/presentation``sanity`Studio plugin for iframe preview and document routing

Next steps

With this architectural understanding, you're ready to start building:

Was this page helpful?