Building with sanity visual editing
Home/Blog/Sanity Visual Editing with Next.js – What You Actually Need to Do to Make It Work

Sanity Visual Editing with Next.js – What You Actually Need to Do to Make It Work

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

June 5, 2026 - 146 views - 10 min read

Share

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.

1 / 2
Visual editing in sanity cms

Click to zoom

How many moving parts

A complete implementation consists of:

  • Sanity client with stega.studioUrl configured
  • 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' and stega: true in 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-sanity on 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

typescript
// 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.

typescript
// 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 published and stega: false
  • In preview always drafts and stega: true
  • Perspective comes either from the header (set by middleware) or from Draft Mode cookies
  • sanityLiveFetch instead of client.fetch — this lets Sanity Live refresh the view after mutations

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.

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

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

typescript
// 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.

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

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

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

Bash
*[_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

typescript
// 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.initial in sanity.config.ts (should be the origin, not an API route)
  • App origin not in allowOrigins

Iframe loads but no outlines

  • stega: false in the preview fetch (most common cause)
  • perspective: 'published' instead of 'drafts'
  • Text cleaned by stegaClean before rendering to DOM
  • Animated text — stega never reaches the final DOM

Click selects the document but not the field

  • Missing path in data-sanity
  • Path doesn't include locale (should be en.title, not title)
  • Array uses index instead of _key

Drafts not visible

  • SANITY_API_READ_TOKEN without 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.enable set 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.

TSX
// Ź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.

1 / 1
Sanity CMS

Click to zoom

This is one of those problems that doesn't appear in the official documentation, yet it reliably surfaces the moment you host Sanity Studio on a separate domain — for example on *.sanity.studio — rather than co-locating it with your Next.js app. Understanding it requires looking at how modern browsers handle cookies inside iframes.

1. The cookie problem inside the iframe

When Sanity Studio is hosted on a different domain (or subdomain) than your Next.js application, the Presentation Tool opens your app inside an iframe. Modern browsers — Safari with ITP in particular, but increasingly Chrome as well — ruthlessly block SameSite=Lax cookies in such cross-origin frames. This means the Next.js draftMode cookie simply never arrives, and draftMode().isEnabled returns false on the server.

2. The dead end in the official documentation

The official Sanity guides recommend conditionally rendering the component on the server:

TSX
{(await draftMode()).isEnabled && <VisualEditing />}

Because cookies are blocked and isEnabled returns false, the <VisualEditing /> component is never rendered in the DOM inside the iframe. When Sanity Studio attempts to establish a connection (a handshake via postMessage), there is nothing on the page to respond. The result is the error: "Unable to connect to visual editing".

3. The side effects of unconditional rendering

The naive workaround is to remove the condition and hardcode <VisualEditing /> in the layout. This fixes the handshake in Studio, but creates a serious UX problem: the floating toolbar (Vercel Toolbar or edit icons) starts appearing on your public-facing site — on localhost or in production whenever a user is logged in or has a stale session. That is absolutely unacceptable.

4. The right balance — VisualEditingWrapper (the solution)

The solution is a dedicated client component that unconditionally renders the element (ensuring the handshake works), but uses the official useIsPresentationTool() hook to hide the editing UI from anyone visiting the page outside of Presentation Tool — via a globally injected CSS rule:

TSX
'use client'

import { VisualEditing } from 'next-sanity/visual-editing'
import { useIsPresentationTool } from 'next-sanity/hooks'
import { useEffect } from 'react'

export default function VisualEditingWrapper() {
  const isPresentationTool = useIsPresentationTool()

  // Always load VisualEditing so it can respond to the iframe handshake (bypasses the cookie problem).
  // If we are NOT inside Presentation Tool, hide the entire UI via CSS.
  useEffect(() => {
    if (!isPresentationTool) {
      const style = document.createElement('style')
      style.innerHTML = `
        sanity-visual-editing { display: none !important; }
      `
      document.head.appendChild(style)
      return () => {
        document.head.removeChild(style)
      }
    }
  }, [isPresentationTool])

  return <VisualEditing />
}

Then in your layout.tsx, simply drop in <VisualEditingWrapper /> with no draftMode conditions whatsoever. The component handles everything: it is always present in the DOM for the handshake, and it hides itself from regular visitors using a CSS rule that fires only when the page is not loaded inside Presentation Tool.

This is the kind of knowledge that only comes from fighting browser limitations in the field — it is nowhere in the official docs, and it will save you hours of debugging.

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.

FAQ

Frequently asked questions

Stega source maps are invisible Unicode metadata characters embedded directly into the string values returned by Sanity's client. Each encoded string carries information about the project ID, dataset, document ID, and field path it came from. When you hover over a DOM element in the Presentation iframe, the @sanity/visual-editing library reads these hidden characters and knows exactly which Sanity field that text belongs to. This is what enables the click-to-edit overlay — without stega, the library has no way to map a rendered string back to its source field.

The most common cause is stega being disabled in your preview fetch — if stega: false is passed (or defaulted) when fetching in preview mode, the strings contain no metadata for the overlay library to read. A related issue is fetching with perspective: 'published' instead of 'drafts', which means draft content and its stega encoding never reach the page. You may also have stegaClean() applied to text before it's rendered to the DOM, which strips the Unicode metadata. Finally, if text is animated via GSAP or similar libraries, the animation engine often replaces the raw string characters, destroying the embedded stega data before it can be read.

Draft Mode in Next.js relies on cookies, but browsers in strict third-party cookie blocking mode — the default in Safari and increasingly enforced in Chrome — will block those cookies when your app is loaded inside Sanity's Presentation iframe. This means draftMode().isEnabled returns false in Server Components, so fetches fall back to published content with stega disabled. The middleware solves this by inspecting each incoming request for signals that it originates from the Sanity iframe (query params, cookies, or the Referer header pointing to *.sanity.studio), then injecting x-sanity-presentation-preview and x-sanity-preview-perspective HTTP headers. Your fetch helper reads these headers as a cookie-independent fallback to activate draft perspective and stega encoding.

When your Next.js app uses [lang]-prefixed routing (e.g. /en, /pl), Sanity Studio may attempt to enable Draft Mode by calling /en/api/draft-mode/enable — but your actual API route lives at /api/draft-mode/enable, so the request returns a 404 and Draft Mode never activates. The fix is to add localized fallback route handlers under src/app/[lang]/api/draft-mode/enable/route.ts (and a matching disable route) that extract the locale from the URL and delegate to your shared enableDraftMode helper. Without these two files, any project with i18n routing will silently fail to enter preview mode when navigating from a localized URL in Presentation.

Automatic stega works well for straightforward string rendering, but breaks down in several scenarios where you need to add the data-sanity attribute manually. If text is animated with GSAP or similar libraries, the animation engine replaces the raw characters and destroys the embedded Unicode metadata. The same happens when text is processed with .split() or .replace() before rendering, or when a GROQ query uses coalesce(select(...)) in a way that causes stega to lose field context. You also need manual data-sanity attributes for elements inside arrays where the _key is dynamic, and for any Client Component that transforms the value before it reaches the DOM.

Every query powering a Visual Editing-enabled component must return _id and _type at the document level — without them, the sanityDataAttribute helper has nothing to encode into the edit intent link, so clicks can't open the correct document in Studio. For any array fields, each item must include its _key in the projection. If _key is missing and you fall back to a numeric index, reordering items in Studio will cause clicks to target the wrong element because the index no longer matches the original position.

This is a cross-origin cookie problem. When Studio is hosted on a different domain (e.g. *.sanity.studio) from your Next.js app, the Presentation Tool loads your app in an iframe. Modern browsers — especially Safari with ITP — block SameSite=Lax cookies in cross-origin iframes, so draftMode().isEnabled returns false on the server. The official pattern of conditionally rendering {(await draftMode()).isEnabled && <VisualEditing />} means the component is never in the DOM, so Studio's postMessage handshake has nothing to respond to. The fix is a VisualEditingWrapper client component that always renders <VisualEditing /> (ensuring the handshake works), but uses the useIsPresentationTool() hook to inject a CSS rule that hides the editing UI when the page is not loaded inside Presentation Tool.