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.
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:
- 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-sanitypackage provides a route helper for this. - Draft fetching with stega. In draft mode, queries use the
previewDraftsperspective, a read token, andstega: { enabled: true, studioUrl }, so strings come back encoded. On published requests, stega is off and the perspective ispublished. - 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.
Implementation
// 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.
generateMetadataruns in draft mode too. Clean titles and descriptions withstegaCleanbefore 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.
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.