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/clientpackage installed - A Studio URL where editors access Sanity Studio
Install the client
npm install @sanity/clientpnpm add @sanity/clientyarn add @sanity/clientbun add @sanity/clientBasic 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. ThewithKeyArraySelectorformat 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 MapA 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_idand_type. For cross-dataset references, documents also include_projectIdand_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'sdocumentandpathvalues are indices into thedocumentsandpathsarrays.
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:
| Value | Unicode | Name |
|---|---|---|
| 0 | U+200B | Zero Width Space |
| 1 | U+200C | Zero Width Non-Joiner |
| 2 | U+200D | Zero Width Joiner |
| 3 | U+FEFF | Byte 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, orseopath 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 encodingsourcePath: the path in the source documentresultPath: the path in the query resultsourceDocument: 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 }),
})
}Security note: the token is used server-side only. Your server renders HTML with draft content, but the token itself never reaches the browser. Make sure SANITY_API_READ_TOKEN is not prefixed with VITE_, NEXT_PUBLIC_, or any other prefix that exposes environment variables to client-side code.
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 URLworkspace: the Studio workspace (omitted ifdefault)id: the published document ID (draft and version prefixes are stripped)type: the document_typepath: the field path in Studio path format (for example,title,body[0].children[0].text)tool: the Studio tool (omitted ifdefault)
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.projectIdanddataset: 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-25or later. - Check stega configuration: when
stega.enabledistrue, the client automatically requests source maps. If you're requesting them manually, useresultSourceMap: '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
documentValuesources are encoded. Computed values (GROQ projections, coalescing) may haveliteralorunknownsource 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
- Architecture overview: understand how the client fits into the broader visual editing system
- Implementing preview/draft mode: build the endpoints that toggle between perspectives
- Enabling overlays and click-to-edit: use the stega-encoded content to power click-to-edit
- Real-time content updates: keep the preview in sync with Studio edits
- Configuring the Presentation Tool: set up the Studio plugin that hosts the preview