Visual Editing

Client setup and stega for visual editing

Configure the Sanity client for visual editing: stega encoding, Content Source Maps, perspectives, and secure token handling.

The Sanity client is the foundation of visual editing. When configured for visual editing, it requests Content Source Maps from the Content Lake and embeds source metadata into string values using stega encoding. This invisible metadata powers click-to-edit overlays and connects rendered content back to its source documents and fields in Sanity Studio.

This guide covers how to configure the client for visual editing, how stega encoding and Content Source Maps work at a technical level, how to handle encoded values in your application, and how to use perspectives to switch between published and draft content.

Prerequisites

  • A Sanity project with content in the Content Lake
  • The @sanity/client package installed
  • A Studio URL where editors access Sanity Studio

Install the client

Basic configuration

Enable stega encoding by setting stega.enabled to true and providing your Studio URL:

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',
  },
})

When stega.enabled is true, the client automatically:

  • Requests Content Source Maps from the API by adding resultSourceMap: 'withKeyArraySelector' to every query. The withKeyArraySelector format uses stable _key-based selectors for array items instead of numeric indices, which prevents overlays from breaking when array items are reordered.
  • Dynamically imports the stega encoding module (keeping it out of your production bundle when disabled).
  • Encodes source metadata into every string value in the query result as invisible zero-width Unicode characters.
  • Cleans query parameters with stegaClean() before sending them to the API, preventing stega-encoded strings from a previous query result from corrupting subsequent queries.

How Content Source Maps work

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 use a compact, index-based structure to minimize payload size.

When you fetch with stega enabled, the API returns both the result and its source map:

// The client handles this internally, but here's what the raw response looks like
const response = await client.fetch(query, params, { filterResponse: false })

// response.result contains your query data
// response.resultSourceMap contains the Content Source Map

A Content Source Map has three parts:

{
  "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 }
    }
  }
}
  • documents: an array of source documents, each with _id and _type. For cross-dataset references, documents also include _projectId and _dataset.
  • paths: an array of JSON path strings pointing to fields in the source documents.
  • mappings: a map connecting result paths to their sources. Each mapping's document and path values are indices into the documents and paths arrays.

The index-based structure avoids duplication. When multiple values come from the same document, they all reference the same index in the documents array.

Mapping types

Not all values in a query result have a direct document source. Mappings have three source types:

  • documentValue: the value comes directly from a document field. This is the most common type and the one that enables click-to-edit.
  • literal: the value is computed or literal (for example, a GROQ projection that concatenates strings). These values can't be traced to a single field.
  • unknown: the source can't be determined. These are skipped during encoding.

Only documentValue sources are stega-encoded. The other types are left unchanged.

How stega encoding works

Stega encoding embeds Content Source Map data as invisible characters appended to string values. The encoding uses four zero-width Unicode characters as a base-4 alphabet:

ValueUnicodeName
0U+200BZero Width Space
1U+200CZero Width Non-Joiner
2U+200DZero Width Joiner
3U+FEFFByte Order Mark

For each string value with a documentValue mapping, the client:

  • Builds a Studio intent URL from the source document ID, type, and field path.
  • Creates a JSON payload: {"origin":"sanity.io","href":"<studio-intent-url>"}.
  • UTF-8 encodes the JSON to bytes.
  • Encodes each byte as four invisible characters (two bits per character).
  • Prepends a four-character marker (four U+200B characters) to identify the encoded sequence.
  • Appends the entire invisible sequence to the original string value.

A typical payload is around 200 bytes, resulting in approximately 800 invisible characters per encoded string. These characters are invisible when rendered in browsers but detectable by JavaScript, which is how the overlay system finds and decodes them.

What gets encoded

The client encodes string values that have a documentValue source in the Content Source Map. It skips values that would break if invisible characters were appended.

What gets skipped

The default filter skips these values automatically (39 field names in the denylist, plus pattern-based rules):

  • Dates: strings matching a date pattern (for example, 2025-12-01)
  • URLs: strings that parse as URLs with recognized protocols (http, https, mailto, tel, and others)
  • Slugs: values at paths ending with slug.current
  • Internal keys: values at paths where the last segment starts with _ (for example, _type, _ref)
  • ID-like fields: values at paths ending with Id (for example, projectId)
  • SEO and metadata paths: values under meta, metadata, openGraph, or seo path segments
  • Type-related paths: values at paths containing "type" (for example, iconType, blockType)
  • Denylisted field names: 39 specific field names including color, email, hex, href, icon, url, path, slug, and others that are commonly used in non-display contexts

The client also optimizes for Portable Text: when walking the result tree, it only traverses children for block types and text for span types, skipping internal metadata like markDefs and style.

Encoding non-string fields

Stega only encodes string values, so number, boolean, and other non-string fields aren't editable by default. To make a number field editable, cast it to a string in your GROQ query:

*[_type == "property"]{
  name,
  description,
  "beds": string(beds),
  "bathrooms": string(bathrooms)
}

Then parse the value back to a number in your renderer:

const beds = Number(post.beds.toString())

The same pattern works for any non-string field. Use string() in GROQ for the editable representation, and convert back in your application code when you need the typed value.

Custom filtering

Override the default filter to control which values get encoded:

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2025-12-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: 'https://your-studio.sanity.studio',
    filter: (props) => {
      // Skip encoding for a specific document type
      if (props.sourceDocument._type === 'icon') return false

      // Skip encoding for a specific field
      if (props.sourcePath.at(-1) === 'cssClass') return false

      // Fall back to the default filter for everything else
      return props.filterDefault(props)
    },
  },
})

The filter function receives:

  • value: the string value being considered for encoding
  • sourcePath: the path in the source document
  • resultPath: the path in the query result
  • sourceDocument: the source document reference (_id, _type, and optionally _projectId, _dataset)
  • filterDefault: a reference to the default filter function, so you can compose custom logic on top of it

Return true to encode the value, false to skip it.

Cleaning stega-encoded values

Stega-encoded strings contain invisible characters that can break non-display operations like string comparisons, URL construction, date parsing, and length checks. Use stegaClean() to strip the encoding before using values in these contexts:

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

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

// Clean a date string before parsing
const date = new Date(stegaClean(post.publishedAt))

// Clean an entire object (deep clean)
const cleanPost = stegaClean(post)

stegaClean() performs a deep clean: it serializes the value to JSON, strips all invisible character sequences using a regex, and parses it back. This works on strings, objects, and arrays.

The client also cleans query parameters automatically when stega is enabled, so you don't need to manually clean values passed as GROQ query parameters.

Perspectives

Perspectives control which version of documents your queries return. They're the mechanism that switches between published content (for production), draft content (for preview), and content release versions.

published (default)

Returns only published documents. Results are CDN-cached and suitable for production:

const publishedClient = client.withConfig({
  perspective: 'published',
  useCdn: true,
})

drafts

Treats all drafts as if they were published. References between draft documents resolve normally. Results are not cached, ensuring editors always see the latest changes:

const previewClient = client.withConfig({
  perspective: 'drafts',
  useCdn: false, // Required: CDN only caches published content
})

raw

Returns documents with their actual _id prefixes intact. Draft documents appear with their drafts. prefix alongside published versions. For authenticated requests only:

const rawClient = client.withConfig({
  perspective: 'raw',
  useCdn: false,
})

Stacked perspectives (arrays)

For content releases, perspectives can be a priority-ordered array. The system resolves content by trying each perspective in order, returning the first match:

const releaseClient = client.withConfig({
  perspective: ['summer-drop', 'drafts', 'published'],
})

This tells the client: "Show the summer-drop release version of each document if it exists, fall back to the draft version, then fall back to the published version." Release IDs are arbitrary strings assigned when the release is created in the Studio.

CDN caching must be disabled for array perspectives (useCdn: false), as with the drafts perspective.

The Presentation Tool communicates the active perspective to your frontend (including release perspectives) via the sanity-preview-perspective cookie. See implementing preview/draft mode for how to parse this value and pass it to the client.

Switching perspectives based on preview state

In practice, you switch perspectives based on whether draft mode is active. Rather than hardcoding 'drafts', use the perspective value communicated by the Presentation Tool, which may be a stacked array for content releases:

import { createClient, type ClientPerspective } from '@sanity/client'

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

export function getClient(perspective: ClientPerspective = 'published') {
  const isPreview = perspective !== 'published'
  return baseClient.withConfig({
    perspective,
    useCdn: !isPreview,
    stega: { enabled: isPreview },
    // Token required server-side to fetch draft/release content.
    // The API silently returns only published documents without it.
    ...(isPreview && { token: process.env.SANITY_API_READ_TOKEN }),
  })
}

The withConfig() method creates a new client instance with merged configuration. Stega config is merged shallowly, so you can toggle individual properties without repeating the full stega object.

Per-request stega override

Disable stega encoding for a single query without creating a new client:

// Fetch without stega encoding (e.g., for sitemap generation)
const posts = await client.fetch(
  '*[_type == "post"]{ title, "slug": slug.current }',
  {},
  { stega: false }
)

This is useful when you need clean values for a specific operation (like generating a sitemap or RSS feed) but want stega enabled for the rest of your application.

Dynamic Studio URLs

If your Studio URL varies by document type or dataset, pass a function instead of a string:

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2025-12-01',
  useCdn: true,
  stega: {
    enabled: true,
    studioUrl: (sourceDocument) => {
      // Route cross-dataset references to a different Studio
      if (sourceDocument._projectId && sourceDocument._projectId !== 'your-project-id') {
        return `https://other-studio.sanity.studio`
      }
      return 'https://your-studio.sanity.studio'
    },
  },
})

The function receives the source document reference (including _id, _type, and optionally _projectId and _dataset for cross-dataset references) and returns a Studio URL string or an object with baseUrl, workspace, and tool properties.

The Studio intent URL

When the overlay system decodes stega data from a string, it extracts a Studio intent URL that points directly to the source document and field. The URL follows this format:

{baseUrl}/{workspace}/intent/edit/mode=presentation;id={id};type={type};path={path}[;tool={tool}]
  • baseUrl: your Studio URL
  • workspace: the Studio workspace (omitted if default)
  • id: the published document ID (draft and version prefixes are stripped)
  • type: the document _type
  • path: the field path in Studio path format (for example, title, body[0].children[0].text)
  • tool: the Studio tool (omitted if default)

The URL may also include search parameters:

  • perspective: included for published IDs (?perspective=published) and version IDs (for example, ?perspective=summer-drop). Draft IDs don't include a perspective parameter.
  • projectId and dataset: included for cross-dataset references, so the Studio knows which project and dataset the document belongs to.

Troubleshooting

Stega characters appear as visible junk in the UI

This usually means the content is being rendered in a context that doesn't support zero-width characters (for example, a plain text email or a terminal). Use stegaClean() to strip encoding before outputting to non-browser contexts.

Stega encoding breaks string comparisons or URL routing

Use stegaClean() before comparing or using values in logic. The default filter skips slug.current paths and URLs, but custom fields used in routing may need manual cleaning.

Content Source Maps are missing from API responses

  • Check the API version: Content Source Maps require API version 2021-03-25 or later.
  • Check stega configuration: when stega.enabled is true, the client automatically requests source maps. If you're requesting them manually, use resultSourceMap: 'withKeyArraySelector'.

Overlays don't appear on some content

  • Check the filter: the default filter skips dates, URLs, slugs, and 39 denylisted field names. If a field you expect to be clickable is being skipped, use a custom filter to include it.
  • Check the mapping type: only documentValue sources are encoded. Computed values (GROQ projections, coalescing) may have literal or unknown source types that can't be traced to a single field.

Debugging stega encoding

To see which fields are being encoded and which are being skipped, pass console as the logger option:

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

The client logs a table of encoded paths (with values and lengths) and a list of skipped paths to the console on each query. This works in both browser dev tools and server-side logs.

To inspect the raw Content Source Map for a single query, set filterResponse: false on the fetch call:

const { result, resultSourceMap } = await client.fetch(
  query,
  params,
  { filterResponse: false }
)

console.log(resultSourceMap)

Without filterResponse: false, the client returns just the result field. With it set to false, the full API response (including resultSourceMap) is returned, which is useful when debugging mapping issues or building custom CSM-aware tooling.

Bundle size concerns

The stega encoding module is dynamically imported and only loaded when stega.enabled is true. In production (where stega is typically disabled), the encoding code is not included in your bundle.

Advanced: CSM utilities for framework authors

The client exports lower-level Content Source Map utilities at @sanity/client/csm, including resolveMapping(), resolveEditInfo(), createEditUrl(), walkMap(), and applySourceDocuments(). The applySourceDocuments() function is particularly useful for optimistic updates, where you apply local document changes to a query result using the CSM for field tracing. See real-time content updates for more on live update patterns.

Next steps

Was this page helpful?