# Visual editing architecture overview

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:

```text
┌─────────────────────────────────────────────────────┐
│  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.

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

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

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

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

1. The Presentation Tool sends a request to your `/api/draft-mode/enable` endpoint with a secret token.
2. Your server validates the token using `@sanity/preview-url-secret`.
3. If valid, your server sets a cookie or session flag to enable draft mode.
4. 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

| Framework | Support level | Notes |
| --- | --- | --- |
| Next.js (App Router) | Full | Page building experience via `defineLive`. Use `next-sanity`. |
| Next.js (Pages Router) | Full | Loaders pattern. Use `next-sanity`. |
| Remix | Full | Loaders pattern. |
| Nuxt | Full | Loaders pattern. Use `@nuxtjs/sanity`. |
| SvelteKit | Full | Loaders pattern. Use `@sanity/svelte-loader`. |
| Astro | Basic | Server-side support via SSR/hybrid mode. Use `@sanity/astro`. |
| Vanilla TypeScript or any framework | Basic | Direct 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](https://www.sanity.io/docs/visual-editing/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):

```json
{
  "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:

| Character | Unicode | Name |
| --- | --- | --- |
| `​` | U+200B | Zero Width Space |
| `‌` | U+200C | Zero Width Non-Joiner |
| `‍` | U+200D | Zero Width Joiner |
| `﻿` | U+FEFF | Byte 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](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega) 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:

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

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

### Perspectives

[Perspectives](https://www.sanity.io/docs/content-lake/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()`:

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

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

| Concern | Framework library | Custom integration |
| --- | --- | --- |
| Stega encoding | Automatic via client config | Automatic via client config |
| Perspective switching | Automatic based on preview state | Manual via `client.withConfig()` |
| Draft mode toggle | Provided (for example, `defineEnableDraftMode`) | Build your own enable/disable endpoints |
| Overlay rendering | Provided (for example, `<VisualEditing />`) | Call `enableVisualEditing()` directly |
| Live updates | Provided (for example, `<SanityLive />`) | Subscribe to the Live Content API |
| Caching and revalidation | Framework-optimized | Implement your own strategy |
| Router integration | Automatic | Wire 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

| Package | npm | Role |
| --- | --- | --- |
| `@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:

- **Setting up the Sanity client for visual editing:** configure stega encoding, perspectives, and Content Source Maps.
- **Implementing preview/draft mode:** build secure enable/disable endpoints for your framework.
- **Enabling overlays and click-to-edit:** integrate `@sanity/visual-editing` with your frontend.
- **Real-time content updates:** subscribe to the Live Content API for instant preview updates.
- **Configuring the Presentation Tool:** set up the Studio plugin with document resolvers and preview URLs.

