Sanity is a headless CMS where you define content with a code-based schema and fetch it via GROQ. Simple enough on its own. Presentation Tool and Visual Editing are a different story — it's the feature that lets a client or editor click directly on any text on the page and immediately see that specific field open in the Studio panel. No hunting for the document in a list, no guessing which field controls which heading. One click and you're editing.
I've implemented it across several projects (for example “Interactive graphic designer portfolio”): different document structures, i18n in two languages, singletons and dynamic collections. Each project surfaced the same or similar problems — the iframe loads, the page looks normal, but hovering highlights nothing, clicking selects nothing, and the Studio console shows Unable to connect to visual editing. From those experiences I built an internal blueprint that I keep and update after each new implementation.
This post is based on that blueprint. I'm not rewriting the Sanity docs — I'm focusing on what's missing from them: specific problems, their root causes, and the solutions that actually worked for me.
Because working with technical docs can feel like that classic meme:
Class example: 1+1=2.
Exam question: You have 4 apples. You ate one and gave another to a friend. Calculate the mass of the sun.
What Visual Editing actually is
Before I get to the code, one clarification — without it, it's easy to debug the wrong thing.
Sanity Visual Editing is not just "an iframe with Sanity Studio".
Real Visual Editing works like this: you click on text in the preview, and Studio on the right automatically opens the correct document and jumps to exactly that field.
For this to work, Sanity embeds invisible metadata into rendered text strings — the so-called stega source maps. These are Unicode characters encoding information: which project, which dataset, which document, which field path. When you hover over a DOM element, the @sanity/visual-editing library reads that metadata and knows where the text came from.
Alternatively, you can manually add a data-sanity attribute to a DOM element that encodes the same information directly in HTML.
If the iframe loads but there are no outlines or highlights on hover — that's exactly what's missing: either stega, or data-sanity. Just having <VisualEditing /> in the layout isn't enough.

How many moving parts
A complete implementation consists of:
- Sanity client with
stega.studioUrlconfigured - Sanity Live (
defineLive) to refresh the preview after changes - Draft Mode — an API route that enables/disables draft preview
- A fetch helper that switches to
perspective: 'drafts'andstega: truein preview - Middleware/proxy that recognizes requests from the Sanity iframe and sets the appropriate headers
<VisualEditing />and<SanityLive />in the root layout- Presentation resolver — maps app URLs to document types
data-sanityon rendered DOM elements
Every single one of these is required. Skipping any one of them produces a different symptom — and that's exactly what makes diagnosis so frustrating.
The client and stega
// src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
export const studioUrl =
process.env.NEXT_PUBLIC_SANITY_STUDIO_URL ||
'https://your-studio.sanity.studio'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || '',
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2026-02-01',
useCdn: true,
perspective: 'published',
stega: {
studioUrl,
},
})stega.studioUrl is set once in the client config — it specifies where Studio is hosted so that source maps contain correct edit intent links. In the preview fetch you don't repeat studioUrl, but you do need to activate encoding via stega: true — more on that in a moment.
The fetch helper — where the switching magic happens
This is the central piece of the implementation. One helper that behaves like a normal client in build and production, and switches to drafts and stega in preview.
// src/sanity/lib/fetch.ts
import 'server-only'
import { PHASE_PRODUCTION_BUILD } from 'next/constants'
import { cookies, draftMode, headers } from 'next/headers'
import { resolvePerspectiveFromCookies } from 'next-sanity/live'
import { sanityLiveFetch } from './live'
async function resolveLiveFetchOptions() {
if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) {
return { perspective: 'published' as const, stega: false }
}
const [{ isEnabled }, headerStore] = await Promise.all([
draftMode(),
headers(),
])
const hasPreviewHeader =
headerStore.get('x-sanity-presentation-preview') === '1'
const headerPerspective = headerStore.get('x-sanity-preview-perspective')
if (!isEnabled && !hasPreviewHeader && !headerPerspective) {
return { perspective: 'published' as const, stega: false }
}
const perspective =
headerPerspective && headerPerspective !== 'raw'
? (headerPerspective as 'drafts' | 'previewDrafts')
: (await resolvePerspectiveFromCookies({ cookies: await cookies() })) ??
'drafts'
return { perspective, stega: true }
}
export async function sanityFetch<T>({
query,
params = {},
tags = ['sanity'],
}: {
query: string
params?: Record<string, unknown>
tags?: string[]
}): Promise<T> {
const { data } = await sanityLiveFetch({
query,
params,
tags,
...(await resolveLiveFetchOptions()),
})
return data as T
}Key rules:
- In build always
publishedandstega: false - In preview always
draftsandstega: true - Perspective comes either from the header (set by middleware) or from Draft Mode cookies
sanityLiveFetchinstead ofclient.fetch— this lets Sanity Live refresh the view after mutations
The cookie problem in iframes and why you need middleware
This is the most common source of problems in production deployments.
Draft Mode in Next.js works via cookies. Sanity Presentation opens your app in an iframe. Browsers in strict third-party cookie blocking mode (default in Safari, increasingly restrictive in Chrome) can block those cookies in an iframe context. The result: the Draft Mode cookie never reaches Server Components, draftMode().isEnabled returns false, the fetch goes through published, stega is disabled, no outlines appear.
The solution: middleware that recognizes requests from the Sanity iframe and adds custom HTTP headers to them — independent of cookies.
// middleware.ts (fragment)
function isSanityReferer(request: NextRequest) {
const referer = request.headers.get('referer')
if (!referer) return false
try {
const { hostname } = new URL(referer)
return (
hostname === 'sanity.io' ||
hostname.endsWith('.sanity.io') ||
hostname.endsWith('.sanity.studio')
)
} catch {
return false
}
}
function isSanityPreviewRequest(request: NextRequest) {
const url = request.nextUrl
return (
url.searchParams.has('sanity-preview-perspective') ||
url.searchParams.has('sanity-preview-secret') ||
Boolean(request.cookies.get('sanity-preview-perspective')?.value) ||
isSanityReferer(request)
)
}
function withSanityPreviewHeaders(request: NextRequest) {
if (!isSanityPreviewRequest(request)) {
return NextResponse.next()
}
const requestHeaders = new Headers(request.headers)
const perspective =
request.nextUrl.searchParams.get('sanity-preview-perspective') ||
request.cookies.get('sanity-preview-perspective')?.value ||
'drafts'
requestHeaders.set('x-sanity-presentation-preview', '1')
requestHeaders.set('x-sanity-preview-perspective', perspective)
return NextResponse.next({ request: { headers: requestHeaders } })
}Important: the referer from Studio is a reliable signal even when the query string disappears after the first redirect. Don't skip that branch.
On the Server Components side you check both signals:
// src/sanity/preview.ts
export async function isSanityPreviewRequest() {
const [{ isEnabled }, headerStore] = await Promise.all([
draftMode(),
headers(),
])
return isEnabled || headerStore.get('x-sanity-presentation-preview') === '1'
}Draft Mode API route — and the i18n trap
// src/app/api/draft-mode/enable/route.ts
import { enableDraftMode } from '@/sanity/draft-mode'
export async function GET(request: Request) {
return enableDraftMode(request)
}Nothing complicated. The problem shows up with i18n.
If the app has routing based on [lang] (e.g. /en, /pl) and the user clicks a link in Presentation that leads to /en/somepage, Studio may try to call Draft Mode at /en/api/draft-mode/enable. That route doesn't exist — you get a 404, Draft Mode never enables.
The fix: add localized fallbacks.
// src/app/[lang]/api/draft-mode/enable/route.ts
import { getLocale } from '@/lib/i18n'
import { enableDraftMode } from '@/sanity/draft-mode'
export async function GET(request: Request) {
const lang = getLocale(new URL(request.url).pathname.split('/')[1])
return enableDraftMode(request, `/${lang}`)
}
Same for disable. Two files, but without them in an i18n project you'll be debugging a 404 whose cause isn't obvious at first glance.
Explicit data-sanity — when stega isn't enough
Automatic stega in text strings works well for simple cases. It breaks down when:
- text is animated (GSAP and similar libraries replace Unicode characters)
- text is split by
.split()or.replace() - text comes from
coalesce(select(...))in GROQ and stega loses context - the element is in an array of objects with a dynamic
_key - the component is a Client Component and modifies the value before render
In these situations you add data-sanity manually.
Helper:
// src/sanity/visual-editing.ts
import { createDataAttribute } from 'next-sanity'
import { dataset, projectId, studioUrl } from '@/sanity/lib/client'
import type { Locale } from '@/lib/i18n'
export function localizedSanityPath(locale: Locale, path: string | unknown[]) {
return Array.isArray(path) ? [locale, ...path] : [locale, path]
}
export function keyedPath(key: string | undefined, fallbackIndex: number) {
return key ? [{ _key: key }] : [fallbackIndex]
}
export function sanityDataAttribute({
id,
type,
path,
}: {
id?: string
type?: string
path: string | unknown[]
}) {
if (!id || !type) return undefined
return createDataAttribute({
baseUrl: studioUrl,
dataset,
projectId,
id,
type,
path,
}).toString()
}Usage in a component:
// Singleton (e.g. home page)
const editAttr = (path: string | unknown[]) =>
sanityDataAttribute({
id: data?._id || 'homePage',
type: data?._type || 'homePage',
path: localizedSanityPath(locale, path),
})
<h1 data-sanity={editAttr('heroTitle')}>{data?.heroTitle}</h1>
// Array of objects
{study.process.map((step, index) => (
<p
key={step._key || index}
data-sanity={sanityDataAttribute({
id: study._id,
type: 'caseStudy',
path: localizedSanityPath(locale, [
'process',
...keyedPath(step._key, index),
'description',
]),
})}
>
{step.description}
</p>
))}Key point: arrays must use _key, not index. If the user reorders items in Studio, a numeric index will point to the wrong element. _key is stable.
GROQ queries — what must come back
Every query for a component with Visual Editing must return:
*[_type == "caseStudy" && slug.current == $slug][0] {
_id,
_type,
"slug": slug.current,
"title": coalesce(select($lang == "pl" => pl.title, en.title), title),
"process": coalesce(
select($lang == "pl" => pl.process[] { _key, number, title, description },
en.process[] { _key, number, title, description }),
process[] { _key, number, title, description }
)
}
Without _id and _type the sanityDataAttribute helper has nothing to encode in the attribute. Without _key in arrays, a click can land on the wrong element after reordering.
Presentation resolver — paths must be exact
// src/sanity/presentation/resolve.ts
const mainDocuments = defineDocuments([
{
route: ['/', '/en', '/pl'],
type: 'homePage',
},
{
route: ['/en/about', '/pl/about'],
type: 'aboutPage',
},
{
route: ['/en/case/:slug', '/pl/case/:slug'],
filter: `_type == "caseStudy" && slug.current == $slug`,
},
])Paths in the resolver must exactly match the real Next.js routing. If the physical route is src/app/[lang]/case/[slug]/page.tsx, then the URL is /en/case/my-slug — the resolver cannot point to /case/my-slug.
This is also a common cause of 404s in preview. Presentation tries to open the URL from the resolver, and it doesn't exist in the app.
Most common symptoms and causes
Unable to connect to visual editing
- Missing
<VisualEditing />in the layout - Wrong
previewUrl.initialinsanity.config.ts(should be the origin, not an API route) - App origin not in
allowOrigins
Iframe loads but no outlines
stega: falsein the preview fetch (most common cause)perspective: 'published'instead of'drafts'- Text cleaned by
stegaCleanbefore rendering to DOM - Animated text — stega never reaches the final DOM
Click selects the document but not the field
- Missing
pathindata-sanity - Path doesn't include locale (should be
en.title, nottitle) - Array uses index instead of
_key
Drafts not visible
SANITY_API_READ_TOKENwithout draft access- Middleware not setting preview headers
- Cookies blocked in iframe with no header fallback
404 on /en/api/draft-mode/enable
- Missing localized fallback route
previewMode.enableset to a URL with a locale prefix
The one thing not to do
One mistake I've seen in several projects: stegaClean on text visible in the DOM.
// ŹLE
<h1>{stegaClean(data?.heroTitle)}</h1>
// DOBRZE
<h1>{data?.heroTitle}</h1>stegaClean strips stega metadata from a text string. Use it only for: slugs in URLs, <head> metadata, values passed to external libraries (e.g. animations, maps) where hidden Unicode characters can cause issues. On elements rendered to the DOM — leave the stega in.

Is it even worth it?
Short answer: yes — but not for every project, and not for every client.
Sanity Visual Editing makes sense where content is edited regularly by someone who doesn't know the CMS structure and has no interest in learning it. Instead of hunting for the right document in a list and then the right field inside it — the client clicks on text on the page and edits. Fewer emails asking "where do I change this heading," less frustration with updates. For agencies, it's also a sales argument: you can show the client a working, editable preview before signing off on delivery.
The problem is that the implementation is disproportionately complex relative to what shows on the surface. Every single one of these pieces can fail silently — no console error, no hint of what went wrong. The iframe loads. The page looks fine. The only clue you get is a hover with no overlay and a click that does nothing.
That's why it's worth building a solid configuration once and keeping it as a reusable blueprint for future projects. That's exactly what this post is for me — and I hope it saves someone a few hours of debugging.


