# Client setup and stega for visual editing

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

```sh
npm install @sanity/client
```

## Basic configuration

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

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

When `stega.enabled` is `true`, the client automatically:

1. 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.
2. Dynamically imports the stega encoding module (keeping it out of your production bundle when disabled).
3. Encodes source metadata into every string value in the query result as invisible zero-width Unicode characters.
4. 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:

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

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

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

1. Builds a Studio intent URL from the source document ID, type, and field path.
2. Creates a JSON payload: `{"origin":"sanity.io","href":"<studio-intent-url>"}`.
3. UTF-8 encodes the JSON to bytes.
4. Encodes each byte as four invisible characters (two bits per character).
5. Prepends a four-character marker (four U+200B characters) to identify the encoded sequence.
6. 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:

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

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

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

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

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

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

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

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

```typescript
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](https://www.sanity.io/docs/implementing-preview-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:

```typescript
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 }),
  })
}
```

> [!NOTE]
> **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:

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

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

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

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

```typescript
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](https://www.sanity.io/docs/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

