DEV FIELDNOTES
Sanity field guide 004Updated July 24, 2026

Sanity Visual Editing overlays are missing or clicking the wrong field.

A layer-by-layer debugging guide for missing overlays, broken click-to-edit links, incorrect field paths, stega leaks, iframe failures, and live updates in Next.js App Router.

A browser preview with editable content overlays connected to structured fields, alongside a broken source-map connection being diagnosed

The Presentation Tool loads the correct Next.js page and even shows unpublished content, but no blue editing overlays appear. Or the overlays appear and clicking one opens the wrong document, the wrong field, or nothing at all. Another common variation is that plain text works while images, arrays, and custom components remain impossible to select.

These symptoms all live under “Visual Editing,” but they fail at different layers. The rendered value needs a Content Source Map, the Sanity client must encode that map into the value, the page must preserve the encoded value in a safe DOM location, the overlay runtime must discover it, and the embedded preview must maintain a connection to the correct Studio. Test those layers in order and the failure becomes much smaller.

Visual Editing is a source-mapping pipeline

Click-to-edit does not infer a field from the words visible on screen. Sanity returns source-map information with a query result. Stega encoding attaches that information to string values as invisible characters. The VisualEditing component scans the rendered DOM, decodes the document ID and field path, and draws an interactive layer over the matching element. A click then sends that location to the parent Studio.

Separate preview from overlays

Draft content appearing proves that authentication and perspective switching work. It does not prove that source maps, stega encoding, DOM discovery, or Studio communication work.

Start from the current App Router shape

For current Next.js and next-sanity projects, defineLive is the main integration point. It provides a preview-aware sanityFetch function and the SanityLive component. The client also needs the deployed Studio URL so decoded source locations have somewhere to send the editor.

src/sanity/lib/client.ts
import {createClient} from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2026-02-01',
  useCdn: true,
  stega: {
    studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL,
  },
})
src/sanity/lib/live.ts
import {defineLive} from 'next-sanity/live'
import {client} from './client'

export const {sanityFetch, SanityLive} = defineLive({
  client,
  serverToken: process.env.SANITY_API_READ_TOKEN,
  browserToken: process.env.SANITY_API_READ_TOKEN,
})
The read token stays server-only

Do not add NEXT_PUBLIC_ to SANITY_API_READ_TOKEN. defineLive can share the Viewer token with the browser during Draft Mode without putting it in the public application bundle.

app/layout.tsx
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {SanityLive} from '@/sanity/lib/live'

export default async function RootLayout({children}) {
  const {isEnabled} = await draftMode()

  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {isEnabled && <VisualEditing />}
      </body>
    </html>
  )
}

Run the six-layer isolation test

1. Prove the page is inside the Presentation Tool

Open the same route both directly and inside the Studio. The overlay layer can render outside the iframe, but click-to-edit navigation needs a connection to the parent Presentation Tool. If you are testing a normal browser tab, a visible overlay that cannot focus the Studio is not a valid end-to-end test.

If the iframe is blank, blocked, or redirected, fix the Presentation Tool origin first. Confirm that previewUrl.origin is the actual frontend origin and that previewMode.enable points to the deployed Draft Mode route.

sanity.config.ts
presentationTool({
  resolve,
  previewUrl: {
    origin: process.env.SANITY_STUDIO_PREVIEW_ORIGIN,
    previewMode: {
      enable: '/api/draft-mode/enable',
    },
  },
})

2. Prove Draft Mode is active

VisualEditing should normally mount only during Draft Mode. Temporarily log draftMode().isEnabled in the root layout. If it is false, the overlay component is absent by design; debug the enable route, redirect, hostname, and cookie before touching stega.

Temporary server diagnostic
const {isEnabled} = await draftMode()
console.info({draftMode: isEnabled})

3. Prove the rendered query uses sanityFetch

A page can fetch drafts correctly with a custom client and still have no overlays if the returned strings were never stega encoded. Follow the rendered component back to its data loader. It must use the preview-aware sanityFetch call, or a custom fetch configured to request source maps and encode them.

app/posts/[slug]/page.tsx
const {data: post} = await sanityFetch({
  query: POST_QUERY,
  params: {slug},
})

return <h1>{post.title}</h1>
Do not clean the visible value

stegaClean is correct before comparisons, URL construction, or non-visual logic. Calling it on the title that you render removes the source information the overlay needs.

4. Look for stega in a safe rendered string

Inspect a simple text field such as the article title. DevTools may reveal a longer string than the visible characters because stega uses zero-width Unicode characters. If Draft Mode is active but every rendered string is clean, inspect the client stega configuration, the fetch function, and any sanitization step between the query and JSX.

If the source characters exist in the data but disappear in the DOM, look for serialization, trimming, slugification, Markdown processing, or a component library that reconstructs the string. Render the raw value in a plain element as a control test.

5. Prove VisualEditing mounted and discovered the element

Confirm the VisualEditing component exists in the rendered React tree and that no client error prevented it from initializing. Start with one plain text element. Complex rich text, portals, canvas rendering, pseudo-elements, and deeply nested interactive controls introduce additional discovery rules that should not be part of the first test.

Temporary stega diagnostic
<VisualEditing
  onSuspiciousStega={(reports) => {
    for (const report of reports) {
      console.warn('Unsafe stega placement', report)
    }
  }}
/>

The suspicious-stega callback helps find encoded values in attributes, metadata, scripts, styles, form values, and URLs. Those placements can break application behavior and are not valid overlay targets.

6. Prove the decoded Studio destination is correct

If an overlay appears but clicking does nothing, inspect stega.studioUrl. It must identify the Studio that contains the referenced project, dataset, and workspace. A localhost URL in production, an obsolete Studio domain, or the wrong workspace base path can produce an apparently healthy overlay with a dead destination.

.env.local
NEXT_PUBLIC_SANITY_STUDIO_URL=http://localhost:3333
SANITY_STUDIO_PREVIEW_ORIGIN=http://localhost:3000

In production, replace both origins with their HTTPS deployments. Add the frontend origin to the Sanity project CORS list and allow credentials. Preview deployments and custom production domains are separate origins and must be treated deliberately.

Why an overlay opens the wrong field

The query reshapes data without preserving the expected source

Aliases, projections, dereferences, array filters, and computed values can change how a result maps back to the source document. Sanity source maps handle normal GROQ projections, but a value assembled in application code has no single editable origin. Test the unmodified field first, then add transformations one at a time.

A value with no single source field
const displayTitle = post.eyebrow + ': ' + post.title
return <h1>{displayTitle}</h1>
Keep editable fields independently rendered
<p>{post.eyebrow}</p>
<h1>{post.title}</h1>

A list uses unstable React keys

Portable Text blocks and object arrays should keep their Sanity _key values. Index-based React keys can make DOM nodes appear to belong to a different item after reordering. Query _key, pass it through your component boundary, and use it as the React key.

Array rendering
{post.features.map((feature) => (
  <FeatureCard key={feature._key} feature={feature} />
))}

The editable value belongs to a referenced document

A category name, author name, or reusable callout may come from a referenced document rather than the page document. Opening that referenced record is correct. If editors expect the page document instead, change the content model or explicitly design a different editing interaction; do not discard accurate source information to hide the relationship.

Images and non-string controls need explicit annotation

Stega naturally travels through strings. Image objects, boolean controls, numbers, empty fields, and layout containers may need explicit data-sanity attributes or a dedicated editable wrapper. Use the overlay utilities documented for your installed visual-editing version instead of placing encoded JSON into a DOM attribute.

Failure patterns in production

Overlays work locally but disappear after deployment

Check the production Studio URL, frontend preview origin, Sanity CORS origins, Viewer token, and Draft Mode cookie on the custom hostname. Then confirm the deployed build contains the same next-sanity version as local. This is usually an environment contract, not a rendering bug.

Text overlays work, but Portable Text blocks do not

Confirm the Portable Text query preserves _key and does not flatten blocks into plain strings before rendering. Test a normal text block without a custom serializer. Then reintroduce custom components and verify each component renders the original stega-enabled children.

The overlay is offset or has the wrong size

Transforms, zoom, sticky containers, nested scrolling regions, portals, and pseudo-elements can make the visual box differ from the text node the runtime discovered. Reduce the element to a normal block in the document flow. Reintroduce layout effects until the geometry breaks.

Clicks work, but edits do not update live

Overlay discovery and live subscriptions are separate layers. If click-to-edit focuses the correct field, stop changing stega and Studio URLs. Confirm SanityLive is mounted, the browser token is available during Draft Mode, the frontend origin is allowed by CORS, and browser requests to the Content Lake are not returning 403.

Metadata, links, or class names behave strangely in preview

A stega-enabled string reached application logic or an unsafe DOM location. Disable stega for generateMetadata and generateStaticParams, and clean values before equality tests, class selection, href construction, or JSON-LD generation. Keep the original encoded value only for visible editable content.

Metadata and route-safe fetch
const {data: post} = await sanityFetch({
  query: POST_QUERY,
  params: {slug},
  stega: false,
})

The shortest reliable verification sequence

Open the page inside the Presentation Tool. Confirm Draft Mode is enabled. Render one unmodified string from sanityFetch in a plain element. Verify that the string carries stega data. Confirm VisualEditing mounted and draws an overlay. Click it and verify the correct Studio workspace, document, and field. Edit the field and confirm SanityLive refreshes the rendered value. Only after that baseline passes should you test Portable Text, arrays, references, images, transformed layouts, and production domains.

Debug one boundary at a time

Iframe, Draft Mode, source map, stega string, DOM element, overlay, Studio focus, and live update are independently testable. The first failed boundary tells you where the repair belongs.

Visual Editing with Next.js App RouterOverlays and click-to-editConfiguring the Presentation ToolSanity and Next.jsNext.js draftMode