# Build a complete visual editing integration

This guide walks through building a complete visual editing integration from scratch using vanilla TypeScript and Vite. By the end, you'll have a working setup where content editors can preview draft content, click on elements to edit them in Sanity Studio, and see changes reflected in real time.

The example uses standard Web APIs and no frontend framework, so you can adapt the patterns to any server-side runtime or framework.

> [!NOTE]
> This isn’t a drop-in solution, but should help you—and your agents—build custom implementations.

## What you'll build

A minimal web application with:

- A Sanity client configured for visual editing with stega encoding.
- Server-side draft mode with secure enable/disable endpoints.
- Click-to-edit overlays powered by `@sanity/visual-editing`.
- Real-time content updates via the Presentation Tool's comlink connection.
- A Presentation Tool configuration in Sanity Studio.

Each step builds on the previous one and produces a testable result.

## Prerequisites

- A Sanity project with at least one document type (this example uses a `post` type with `title`, `slug`, and `body` fields). Follow the [Studio quick start](https://www.sanity.io/docs/sanity-studio-quickstart) to get up and running.
- Node.js 20 or later.
- A Sanity Studio deployed or running locally.

## Step 1: create the project and fetch content

Start by scaffolding a Vite project and configuring the Sanity client. We’ll use Vite to bundle the client-side visual editing components to make this example easier to follow.

### Create the project

```sh
npm create vite@latest visual-editing-demo -- --template vanilla-ts
cd visual-editing-demo
npm install @sanity/client @portabletext/to-html
```

The `@portabletext/to-html` package renders Portable Text (the array format Sanity uses for rich text) as HTML. It preserves all text content, including stega-encoded zero-width characters, so overlays work within body content with no extra configuration.

### Configure the client

Create a client with stega encoding enabled. The configuration below creates a base client, then exposes a client configured for visual editing. The `studioUrl` tells the overlay system where your Studio lives:

**src/lib/sanity.ts**

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

// Returns a client configured for the current perspective.
// In preview mode, the perspective comes from the Studio (via cookie)
// and may be a stacked array for content releases.
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.
    // Without it, the API silently returns only published documents.
    ...(isPreview && { token: process.env.SANITY_API_READ_TOKEN }),
  })
}
```

> [!NOTE]
> **Security:** 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. For more detail, see [setting up the Sanity client for visual editing](https://www.sanity.io/docs/visual-editing-client-setup).

### Create a server for rendering

This example uses a basic Node.js HTTP server to avoid confusion. In a real project, you'd use your framework's server (Express, Hono, Fastify, or similar):

**server.ts**

```typescript
import { createServer } from 'node:http'
import { toHTML } from '@portabletext/to-html'
import { getClient } from './src/lib/sanity'

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{
  _id,
  title,
  slug,
  body // Portable Text array
}`

const server = createServer(async (req, res) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`)
  const slug = url.pathname.split('/posts/')[1]

  if (!slug) {
    res.writeHead(404)
    res.end('Not found')
    return
  }

  const client = getClient() // Defaults to 'published' perspective
  const post = await client.fetch(POST_QUERY, { slug })

  if (!post) {
    res.writeHead(404)
    res.end('Post not found')
    return
  }

  // Render the Portable Text body as HTML
  const bodyHtml = toHTML(post.body ?? [])

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
</body>
</html>`)
})

server.listen(3000, () => console.log('Server running at http://localhost:3000'))
```

> [!NOTE]
> **Note on Portable Text and overlays:** `@portabletext/to-html` preserves all text content in spans, including the invisible stega-encoded characters. This means overlays work within body content, and each block element (paragraph, heading, list item) becomes an edit target. For custom block types (images, code blocks, custom embeds), pass a `components` option. [See the documentation for details](https://github.com/portabletext/to-html).

**Test it:** run `npx tsx server.ts` and visit `http://localhost:3000/posts/your-post-slug`. You should see the published content rendered on the page.

> [!WARNING]
> Important
> the `charset=utf-8` declaration in the `Content-Type` header is required. Stega encoding uses Unicode characters in the supplementary plane (U+E0000 range) that browsers will misinterpret without explicit UTF-8 encoding, causing invisible stega data to render as visible characters and breaking overlay detection. Most frameworks set UTF-8 by default, but vanilla Node.js `http` does not.

For more on client configuration, see [setting up the Sanity client for visual editing](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).

## Step 2: add draft mode

Add secure endpoints that the Presentation Tool calls to toggle draft mode.

### Install dependencies

Still in the `visual-editing-demo` directory:

```sh
npm install @sanity/preview-url-secret
```

### Create the enable endpoint

**src/lib/draft-mode.ts**

```typescript
import { validatePreviewUrl } from '@sanity/preview-url-secret'
import { withoutSecretSearchParams } from '@sanity/preview-url-secret/without-secret-search-params'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { validateApiPerspective, type ClientPerspective } from '@sanity/client'
import { getClient } from './sanity'

export function isDraftMode(cookieHeader: string): boolean {
  return cookieHeader.includes(`${perspectiveCookieName}=`)
}

export function getPreviewPerspective(cookieHeader: string): ClientPerspective {
  const regex = new RegExp(`${perspectiveCookieName}=([^;]+)`)
  const match = cookieHeader.match(regex)
  if (!match) return 'drafts'

  // The cookie value may be a comma-separated stacked perspective
  // for content releases (for example, "summer-drop,drafts,published")
  const value = match[1]
  const perspective = value.includes(',') ? value.split(',') : value
  try {
    validateApiPerspective(perspective)
    return perspective === 'raw' ? 'drafts' : perspective
  } catch {
    return 'drafts'
  }
}

export async function handleEnableDraftMode(requestUrl: string): Promise<{
  status: number
  headers: Record<string, string | string[]>
}> {
  // The token is required (validatePreviewUrl throws a TypeError without it)
  if (!process.env.SANITY_API_READ_TOKEN) {
    return { status: 500, headers: {} }
  }

  const client = getClient().withConfig({
    token: process.env.SANITY_API_READ_TOKEN,
  })

  const { isValid, redirectTo, studioPreviewPerspective } = await validatePreviewUrl(
    client,
    requestUrl
  )

  if (!isValid) {
    return { status: 401, headers: {} }
  }

  const cleanRedirect = redirectTo
    ? withoutSecretSearchParams(new URL(redirectTo, requestUrl)).pathname
    : '/'

  const cookies: string[] = [
    `${perspectiveCookieName}=${studioPreviewPerspective || 'drafts'}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`,
  ]

  return {
    status: 307,
    headers: {
      'Set-Cookie': cookies,
      Location: cleanRedirect,
    },
  }
}

export function handleDisableDraftMode(): {
  status: number
  headers: Record<string, string | string[]>
} {
  return {
    status: 307,
    headers: {
      'Set-Cookie': [
        `${perspectiveCookieName}=; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=0`,
      ],
      Location: '/',
    },
  }
}
```

### Update the server to handle draft mode

**server.ts**

```typescript
// server.ts, updated with draft mode support
import { createServer } from 'node:http'
import { toHTML } from '@portabletext/to-html'
import { perspectiveCookieName } from '@sanity/preview-url-secret/constants'
import { getClient } from './src/lib/sanity'
import {
  isDraftMode,
  getPreviewPerspective,
  handleEnableDraftMode,
  handleDisableDraftMode,
} from './src/lib/draft-mode'

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{
  _id,
  title,
  slug,
  body
}`

const server = createServer(async (req, res) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`)
  const cookieHeader = req.headers.cookie || ''

  // Route to draft mode endpoints
  if (url.pathname === '/api/draft-mode/enable') {
    const result = await handleEnableDraftMode(url.toString())
    const setCookie = result.headers['Set-Cookie']
    if (Array.isArray(setCookie)) {
      setCookie.forEach((c) => res.appendHeader('Set-Cookie', c))
    }
    if (result.headers.Location) {
      res.writeHead(result.status, { Location: result.headers.Location })
    } else {
      res.writeHead(result.status)
    }
    res.end()
    return
  }

  if (url.pathname === '/api/draft-mode/disable') {
    const result = handleDisableDraftMode()
    const setCookie = result.headers['Set-Cookie']
    if (Array.isArray(setCookie)) {
      setCookie.forEach((c) => res.appendHeader('Set-Cookie', c))
    }
    res.writeHead(result.status, { Location: result.headers.Location as string })
    res.end()
    return
  }

  // Update the perspective cookie when the editor switches releases in the Studio.
  // The Studio sends the perspective on EVERY page load, not just on change,
  // so we compare against the current value and return 204 if unchanged to
  // avoid an infinite reload loop.
  if (url.pathname === '/api/draft-mode/perspective') {
    const newPerspective = url.searchParams.get('perspective')
    if (!newPerspective || !isDraftMode(cookieHeader)) {
      res.writeHead(400)
      res.end()
      return
    }
    const currentPerspective = getPreviewPerspective(cookieHeader)
    const currentValue = Array.isArray(currentPerspective)
      ? currentPerspective.join(',')
      : currentPerspective
    if (currentValue === newPerspective) {
      // Perspective hasn't changed: no cookie update, no reload needed
      res.writeHead(204)
      res.end()
      return
    }
    res.writeHead(200, {
      'Set-Cookie': `${perspectiveCookieName}=${newPerspective}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=3600`,
    })
    res.end()
    return
  }

  const slug = url.pathname.split('/posts/')[1]
  if (!slug) {
    res.writeHead(404)
    res.end('Not found')
    return
  }

  const preview = isDraftMode(cookieHeader)
  const perspective = preview ? getPreviewPerspective(cookieHeader) : 'published'
  const client = getClient(perspective)
  const post = await client.fetch(POST_QUERY, { slug })

  if (!post) {
    res.writeHead(404)
    res.end('Post not found')
    return
  }

  const bodyHtml = toHTML(post.body ?? [])

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
</body>
</html>`)
})

server.listen(3000, () => console.log('Server running at http://localhost:3000'))
```

This step isn't independently testable yet because the Presentation Tool (configured in Step 4) triggers the enable endpoint automatically. For now, verify the code compiles and move to Step 3. Once the full integration is wired up, you can confirm the `sanity-preview-perspective` cookie is set and that draft content renders correctly.

For the details on how this preview mode implementation works, see [implementing preview/draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).

## Step 3: add click-to-edit overlays and live updates

Add the overlay system so editors can click on content elements to jump to the corresponding field in the Studio. The `enableVisualEditing()` function handles both overlays and real-time content updates through the Presentation Tool's Comlink connection.

### Install dependencies

Still in the `visual-editing-demo` directory:

```sh
npm install @sanity/visual-editing
```

Note that `react` and `react-dom` are required peer dependencies of `@sanity/visual-editing`:

```sh
npm install react react-dom
```

### Create the preview module

Create a module that initializes visual editing. This file is a client component, and it is only loaded when draft mode is active (the server conditionally includes it):

**src/preview.ts**

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

// enableVisualEditing() handles both overlays AND real-time updates.
// It creates Comlink connections for overlay interactions and live
// content sync. Do NOT also call enableLiveMode() from @sanity/core-loader.
// That would create duplicate Comlink nodes and break the handshake.
enableVisualEditing({
  history: {
    subscribe: (navigate) => {
      const handler = () => navigate({
        type: 'pop',
        url: location.href,
      })
      addEventListener('popstate', handler)
      return () => removeEventListener('popstate', handler)
    },
    update: (update) => {
      if (update.type === 'push') history.pushState(null, '', update.url)
      if (update.type === 'replace') history.replaceState(null, '', update.url)
    },
  },
  refresh: (payload) => {
    // Called when the Studio signals a content change.
    // `source: 'manual'` is the editor clicking the refresh button.
    // `source: 'mutation'` is a document mutation in the Studio.
    if (payload.source === 'manual') {
      window.location.reload()
      return new Promise<void>(() => {}) // Never resolves, keeps loading indicator visible during reload
    }
    if (payload.source === 'mutation') {
      // Only reload if the mutation affects the current page.
      // Without this filter, every edit in the Studio would reload every
      // open preview, even unrelated ones. Frameworks with live streaming
      // (for example, next-sanity with the Live Content API) return `false`
      // here and let their data layer update incrementally instead.
      const currentSlug = window.location.pathname.split('/').pop()
      if (payload.document.slug?.current === currentSlug) {
        window.location.reload()
        return new Promise<void>(() => {})
      }
    }
    return false
  },
  // Called when the editor switches perspectives in the Studio
  // (for example, switching to a different content release).
  // The Studio sends the perspective on every page load, not just on change,
  // so the endpoint returns 204 when unchanged. We only reload on 200
  // (actual change) to avoid an infinite reload loop. This is the
  // framework-agnostic equivalent of next-sanity's server action +
  // router.refresh() pattern.
  onPerspectiveChange: async (perspective) => {
    const value = Array.isArray(perspective) ? perspective.join(',') : perspective
    const response = await fetch(
      `/api/draft-mode/perspective?perspective=${encodeURIComponent(value)}`
    )
    // 204 = perspective unchanged (normal page-load sync from Studio)
    // 200 = perspective actually changed, reload to re-render
    if (response.status === 200) {
      window.location.reload()
    }
  },
})
```

### Update the server to include the preview script

Add the conditional script tag to your server's HTML template. This loads the preview module only when draft mode is active:

**server.ts**

```typescript
// In server.ts, update the HTML template:
  res.end(`<!DOCTYPE html>
<html>
<head>
  <title>${post.title}</title>
</head>
<body>
  <article>
    <h1>${post.title}</h1>
    <div class="body">${bodyHtml}</div>
  </article>
  ${preview ? `<script type="module" src="http://localhost:5173/src/preview.ts"></script>` : ''}
</body>
</html>`)
```

The server conditionally includes the preview script only when draft mode is active. The client-side code never needs to detect draft mode because it only runs when the server includes it.

### Start both development servers

You need two processes running: the Node server that renders HTML, and the Vite dev server that serves the preview module. **Both must be running for visual editing to work.**

Open two terminal windows:

```sh
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints)
npx tsx server.ts

# Terminal 2: Vite dev server (serves preview module on port 5173)
npx vite
```

When draft mode is active, the Node server includes `<script type="module" src="http://localhost:5173/src/preview.ts">` in the HTML. Vite's dev server compiles and serves this module (and its dependencies like React and `@sanity/visual-editing`) on the fly. If Vite isn't running, the script tag silently fails and overlays won't appear.

### Add data attributes for non-string content

For content that can't carry stega encoding (like images), use `createDataAttribute()`:

**src/lib/data-attributes.ts**

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

export function getImageAttribute(documentId: string, path: string) {
  return createDataAttribute({
    id: documentId,
    type: 'post',
    path,
    baseUrl: 'https://your-studio.sanity.studio',
  })
}

// In your server-rendered HTML:
// <img data-sanity="${getImageAttribute(post._id, 'mainImage')}" src="..." alt="..." />
```

Because stega encoding is active in draft mode, the rendered text already contains invisible metadata. The overlay system detects this automatically and draws transparent overlays on content elements. Data attributes are only needed for non-string content.

For the full overlay API, see [enabling overlays and click-to-edit](https://www.sanity.io/docs/visual-editing/visual-editing-overlays). For more on real-time update patterns, see [real-time content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates).

## Step 4: configure the Presentation Tool

Set up the Studio plugin that ties everything together.

### Update your Studio configuration

Navigate to your Studio directory and update the config.

**sanity.config.ts**

```typescript
import { defineConfig } from 'sanity'
import { presentationTool, defineDocuments, defineLocations } from 'sanity/presentation'
import { structureTool } from 'sanity/structure'

const mainDocuments = defineDocuments([
  {
    route: '/posts/:slug',
    filter: `_type == "post" && slug.current == $slug`,
  },
])

const locations = {
  post: defineLocations({
    select: {
      title: 'title',
      slug: 'slug.current',
    },
    resolve: (doc) => ({
      locations: [
        {
          title: doc?.title || 'Untitled',
          href: `/posts/${doc?.slug}`,
        },
      ],
    }),
  }),
}

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: 'your-project-id',
  dataset: 'production',
  plugins: [
    structureTool(),
    presentationTool({
      previewUrl: {
        initial: 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
          disable: '/api/draft-mode/disable',
        },
      },
      resolve: {
        mainDocuments,
        locations,
      },
    }),
  ],
})
```

This configuration:

- **previewUrl:** tells the Presentation Tool where your frontend is running and which endpoints toggle draft mode.
- **mainDocuments:** maps URL patterns to documents, so navigating to `/posts/my-post` in the preview automatically opens the corresponding `post` document in the editor.
- **locations:** maps document types to frontend URLs, so the Studio shows editors where each document appears on the site.

For the full Presentation Tool configuration, see [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).

## Test the full system

Run each part of the system:

**In your studio:**

```sh
npx sanity dev
```

**In the visual-editing-demo directory:**

```sh
npx tsx server.ts
```

**Open another terminal window in visual-editing-demo, and run**:

```sh
npx vite
```

Open the Presentation Tool in your Studio. The preview should load your frontend in an iframe, activate draft mode automatically, and show click-to-edit overlays. Clicking an overlay should open the document in the editor pane. Editing a field should update the preview in real time. Navigating in the preview should sync with the Studio's document panel.



## What you've built

Your integration now supports the complete visual editing workflow:

1. **Draft mode:** the Presentation Tool activates draft mode via a secure handshake, switching your frontend to the Studio's requested perspective (which may be `drafts` or a stacked perspective for content releases) with stega encoding active.
2. **Click-to-edit:** editors click on any content element in the preview to jump directly to the corresponding field in the Studio.
3. **Real-time updates:** content changes in the Studio are reflected in the preview instantly via the Comlink connection managed by `enableVisualEditing()`.
4. **Navigation sync:** navigating in the preview updates the Studio's document panel, and clicking document locations in the Studio navigates the preview.

## Production considerations

This example uses Vite's dev server to serve client-side modules during development. For production:

- **Build the client-side code:** run `npx vite build` to produce optimized bundles. Serve the built assets from your production server.
- **Conditional script loading:** the server already conditionally includes the preview script only when draft mode is active. In production, point the `src` attribute to your built asset path instead of the Vite dev server.
- **Environment variables:** store `SANITY_API_READ_TOKEN` as a server-side environment variable. Never expose it to the client.

## Next steps

This example covers the core integration. Here are some areas to explore further:

- **Custom filtering:** control which values get stega-encoded using the client's `stega.filter` option. See [setting up the Sanity client](https://www.sanity.io/docs/visual-editing/visual-editing-client-stega).
- **Shared preview access:** let editors share preview URLs with stakeholders who don't have Studio access. See [implementing preview/draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).
- **Edit groups and data attributes:** group related fields under a single overlay or annotate non-string content. See [enabling overlays and click-to-edit](https://www.sanity.io/docs/visual-editing/visual-editing-overlays).
- **Production real-time updates:** use the Listener API or a framework library's live component for real-time updates outside the Presentation Tool. See [real-time content updates](https://www.sanity.io/docs/visual-editing/live-preview-content-updates).
- **Multiple preview environments:** configure different preview URLs for staging and production. See [configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool).

