Build a complete visual editing integration
Build a complete framework-agnostic visual editing integration step-by-step with Vite and a Node.js HTTP server.
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.
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
posttype withtitle,slug, andbodyfields). Follow the Studio quick start 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
npm create vite@latest -- visual-editing-demo --template vanilla-ts cd visual-editing-demo npm install @sanity/client @portabletext/to-html
pnpm create vite@latest visual-editing-demo --template vanilla-ts cd visual-editing-demo pnpm add @sanity/client @portabletext/to-html
yarn create vite@latest visual-editing-demo --template vanilla-ts cd visual-editing-demo yarn add @sanity/client @portabletext/to-html
bun create vite@latest visual-editing-demo --template vanilla-ts cd visual-editing-demo bun add @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:
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 }),
})
}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.
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):
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 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.
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.
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.
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:
npm install @sanity/preview-url-secretpnpm add @sanity/preview-url-secretyarn add @sanity/preview-url-secretbun add @sanity/preview-url-secretCreate the enable endpoint
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, 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.
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:
npm install @sanity/visual-editingpnpm add @sanity/visual-editingyarn add @sanity/visual-editingbun add @sanity/visual-editingNote that react and react-dom are required peer dependencies of @sanity/visual-editing:
npm install react react-dom
pnpm add react react-dom
yarn add react react-dom
bun add 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):
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:
// 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:
# 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
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints) pnpm dlx tsx server.ts # Terminal 2: Vite dev server (serves preview module on port 5173) pnpm dlx vite
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints) yarn dlx tsx server.ts # Terminal 2: Vite dev server (serves preview module on port 5173) yarn dlx vite
# Terminal 1: Node server (serves HTML on port 3000, handles draft mode endpoints) bunx tsx server.ts # Terminal 2: Vite dev server (serves preview module on port 5173) bunx 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():
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. For more on real-time update patterns, see real-time 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.
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-postin the preview automatically opens the correspondingpostdocument 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.
Test the full system
Run each part of the system:
In your studio:
npx sanity devpnpm dlx sanity dev
yarn dlx sanity dev
bunx sanity devIn the visual-editing-demo directory:
npx tsx server.tspnpm dlx tsx server.ts
yarn dlx tsx server.ts
bunx tsx server.tsOpen another terminal window in visual-editing-demo, and run:
npx vitepnpm dlx viteyarn dlx vitebunx viteOpen 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:
- Draft mode: the Presentation Tool activates draft mode via a secure handshake, switching your frontend to the Studio's requested perspective (which may be
draftsor a stacked perspective for content releases) with stega encoding active. - Click-to-edit: editors click on any content element in the preview to jump directly to the corresponding field in the Studio.
- Real-time updates: content changes in the Studio are reflected in the preview instantly via the Comlink connection managed by
enableVisualEditing(). - 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 buildto 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
srcattribute to your built asset path instead of the Vite dev server. - Environment variables: store
SANITY_API_READ_TOKENas 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.filteroption. See setting up the Sanity client. - Shared preview access: let editors share preview URLs with stakeholders who don't have Studio access. See implementing preview/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.
- 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.
- Multiple preview environments: configure different preview URLs for staging and production. See configuring the Presentation Tool.