Sanity Visual Editing with Stega-Encoded Source Maps

A Sanity-specific application of Live Editing Integration Patterns, this guide connects a Next.js frontend to Sanity’s Presentation tool: drafts render through draft mode and the previewDrafts perspective, updates stream in live, and every string on the page carries an invisible stega encoding that tells the overlay which document and field it came from.

Sanity’s approach to click-to-edit differs from annotation-based systems. Instead of asking developers to add data attributes to every element, the Sanity client can return strings with content source maps encoded as invisible Unicode characters, a technique called stega. The visual editing overlay in the preview reads those characters from the DOM and turns any rendered string into a link to its field in the Studio. That makes coverage almost automatic, with one catch: strings with invisible characters are no longer equal to their plain values, so code that compares or parses content must clean them first.

From GROQ result to clickable textA GROQ query with stega enabled returns strings carrying invisible source-map characters; they render into the DOM unchanged; the visual editing overlay decodes them and links each string to its document and field in the Presentation tool.GROQ querystega: enabledStrings withinvisible source mapRendered DOMlooks unchangedVisual editingoverlay decodesPresentation tooldocument + fieldclick
The mapping travels inside the strings themselves, so components need no annotations for text fields.

The Problem

A publisher moved its magazine to Sanity and Next.js and built a Presentation tool setup following an early tutorial. Editors loved clicking headlines to edit them, but three bugs appeared within a week. A category filter stopped working in preview, because category names carried stega characters and no longer matched the filter values. An image component crashed, because the alt text, now carrying invisible characters, was compared with an empty string to decide whether to render a caption. And the JSON-LD for articles contained the invisible characters too, which would have shipped to search engines had stega been enabled on the published site by mistake.

All three come from the same fact: stega-encoded strings are data with extra characters. They are ideal for rendering text and wrong for logic, identifiers, URLs and metadata.

How Stega and Presentation Work Together

The Presentation tool in Sanity Studio loads your site in an iframe and communicates with it through a channel set up by the visual editing component. Three things must be configured:

  1. Draft mode activation. Presentation opens a URL on your site that enables Next.js draft mode after validating a secret generated by the Studio. The next-sanity package provides a route helper for this.
  2. Draft fetching with stega. In draft mode, queries use the previewDrafts perspective, a read token, and stega: { enabled: true, studioUrl }, so strings come back encoded. On published requests, stega is off and the perspective is published.
  3. The visual editing component and live updates. Rendered only in draft mode, it draws overlays, handles clicks and refreshes the page data when documents change, either through Sanity’s live content API or by refreshing server components.
Where stega is safe and where it must be cleanedCategories of rendered content and whether stega-encoded strings can be used as they are or must be cleaned with stegaClean first.Use of the stringStega OK?WhyHeadings, paragraphs, captionsyesclick-to-edit worksButton labels, alt text rendered as textyesrendered onlyComparisons, filters, switch statementscleanequality breaksURLs, slugs, ids, CSS classescleaninvalid valuesDates and numbers parsed from stringscleanparse failsJSON-LD, meta tags, analyticscleanleaks invisible characters
Render text as it comes; clean everything that is compared, parsed, used as a key or sent outside the page.

Implementation

TypeScript
// sanity/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: "2025-02-19",
  useCdn: true,
  stega: { studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL }, // only used when enabled per request
});

// sanity/fetch.ts: one helper for published and draft requests
import { draftMode } from "next/headers";
import { client } from "./client";

export async function sanityFetch<T>(query: string, params: Record<string, unknown>, tags: string[]): Promise<T> {
  const isDraft = (await draftMode()).isEnabled;
  if (isDraft && !process.env.SANITY_VIEWER_TOKEN) throw new Error("SANITY_VIEWER_TOKEN is required in draft mode");
  return client.fetch<T>(query, params, {
    perspective: isDraft ? "previewDrafts" : "published",
    useCdn: !isDraft,
    stega: isDraft, // encode strings only for the Presentation tool
    token: isDraft ? process.env.SANITY_VIEWER_TOKEN : undefined,
    next: isDraft ? { revalidate: 0 } : { revalidate: 3600, tags },
  });
}

// app/api/draft-mode/enable/route.ts: Presentation tool enables draft mode here
import { defineEnableDraftMode } from "next-sanity/draft-mode";
import { client } from "@/sanity/client";

export const { GET } = defineEnableDraftMode({
  client: client.withConfig({ token: process.env.SANITY_VIEWER_TOKEN }),
});

// app/layout.tsx (excerpt): overlays only in draft mode
import { draftMode } from "next/headers";
import { VisualEditing } from "next-sanity";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const { isEnabled } = await draftMode();
  return (
    <html lang="en">
      <body>
        {children}
        {isEnabled && <VisualEditing />}
      </body>
    </html>
  );
}

// components/CategoryFilter.tsx: clean before logic
import { stegaClean } from "next-sanity";

export function isActiveCategory(categoryTitle: string, active: string): boolean {
  return stegaClean(categoryTitle) === active;
}

In the Studio, add the Presentation tool with previewUrl pointing at your site and previewMode.enable set to /api/draft-mode/enable, and configure document locations so editors can open the right page from any document. Where a component needs an explicit mapping, for example an image or a block without visible text, the createDataAttribute helper from the same package produces a data-sanity attribute that the overlay understands.

For live updates, newer next-sanity versions provide a defineLive helper that returns a sanityFetch and a SanityLive component; with it, draft content refreshes automatically when documents change. The helper above works with either approach: without live content, the visual editing component refreshes server components on document changes, which is slower but has fewer moving parts.

Mapping documents to routes

Presentation works in both directions: clicking an element opens its document, and opening a document in the Studio should navigate the preview to a page that shows it. That second direction needs document locations, a small resolver in the Studio configuration that returns the URLs where a document appears. An article resolves to its own page and to the listing pages of its categories; an author resolves to their profile and their latest articles. Keep the resolver in sync with your routing, because a stale location sends editors to a 404 inside the Studio, which feels like a broken tool even when the preview itself works.

Configuration Reference

Setting Where Value
perspective fetch options previewDrafts in draft mode, published otherwise
stega fetch options true in draft mode only
studioUrl client stega config URL of the Studio that hosts Presentation
SANITY_VIEWER_TOKEN server env Viewer-role token for drafts
previewMode.enable Presentation tool /api/draft-mode/enable
frame-ancestors CSP on preview the Studio’s origin

Gotchas & Edge Cases

  • Stega in metadata. generateMetadata runs in draft mode too. Clean titles and descriptions with stegaClean before returning them, or the browser tab and social previews in preview show odd characters.
  • String length checks. Validation such as “truncate after 160 characters” miscounts encoded strings. Clean before measuring.
  • Portable Text. Text spans inside Portable Text are encoded as well, which enables click-to-edit on paragraphs. Marks, keys and style names should not be relied on as encoded strings.
  • Search and filtering in preview. Client-side search over encoded strings fails to match typed queries. Clean the indexed text or disable stega for the search data query.
  • Published pages. Never enable stega for published fetches. Beyond the invisible characters, encoded strings are larger and leak source-map information about your content structure.

Verifying the Result

Open the Presentation tool, navigate to an article and hover a headline: an overlay should outline it, and clicking should open the headline field of the right document. Edit the field and watch the page update. Then check the page’s HTML source in preview for JSON-LD and meta tags: they must contain no invisible characters. Run the category filter in preview to confirm cleaned comparisons still match. Finally, load the published page and confirm that neither the visual editing script nor encoded strings are present.

Share of rendered text elements with click-to-editPercentage of text elements on an article page that were clickable in the Presentation tool with manual data attributes, with stega only, and with stega plus data attributes for non-text blocks.Manual data attributes38 %Stega only86 %Stega + createDataAttribute99 %
Counted on the publisher's article template; stega covered text automatically, attributes covered images and embeds.

Rollout Checklist

  • Install the Presentation tool with the preview URL and draft-mode enable route.
  • Route every GROQ fetch through the helper that sets perspective, token and stega together.
  • Render the visual editing component only in draft mode.
  • Search the codebase for comparisons, slugs, metadata and parsing of CMS strings, and clean them.
  • Add data attributes for images, embeds and other blocks without text.
  • Add a test that asserts published HTML contains no stega characters.
  • Configure document locations so the Studio can open the preview on the right page for every document type.

Frequently Asked Questions

What exactly are the invisible characters?

They are zero-width Unicode characters appended to string values, encoding a compact reference to the document id, the field path and the Studio URL. Browsers render them as nothing, and copying text from the preview may include them, which is another reason stega belongs only in preview.

Can I use stega without the Presentation tool?

The encoding itself is independent, but its purpose is to be decoded by the visual editing overlay. Without the overlay, it only adds characters. Use it with Presentation or not at all.

Does stega affect Largest Contentful Paint in preview?

Slightly, because every string is longer, but preview performance is rarely measured and never affects readers. The published site does not use stega, so its metrics are unaffected.

Does visual editing work with the Pages Router?

Yes. next-sanity supports both routers, with slightly different draft-mode setup. The stega rules on this page apply identically.

How do I test that stega never reaches production?

Render a published page in a test and assert that no string contains zero-width characters, for example with a regular expression over the HTML for the Unicode ranges stega uses. Run it on every page type in CI.