
Setting Up Sanity Visual Editing in Next.js
To set up Sanity Visual Editing in Next.js, add the presentationTool() plugin to your Studio with a previewUrl that points at your site and a Draft Mode enable path, then wire up the Next.js side: set stega.studioUrl on your next-sanity client, fetch with sanityFetch from defineLive, add a route at /api/draft-mode/enable using defineEnableDraftMode, and render <VisualEditing /> in the root layout when Draft Mode is on. Editors can then open the Presentation tool, see the live site in an iframe, click any text to jump to its field and watch changes appear as they type.
Visual Editing removes the guesswork from headless content editing. Instead of hunting for which document and field produce a heading on the page, editors click it. This guide sets it up step by step with Next.js 16 and next-sanity v13, explains what each piece does so you can debug it, and covers images and other non-text content, production configuration and the problems you're most likely to run into. It assumes you've already connected Sanity with Next.js and have a page rendering a document type.
How Visual Editing Works
There are several moving parts, so it helps to see the whole flow before writing code. When an editor opens the Presentation tool:
- The Studio loads your Next.js site in an iframe, using the URL from
previewUrl. - The Studio calls your site's Draft Mode enable route with a short-lived secret. The route verifies it with Sanity and turns on Next.js Draft Mode for that session.
- With Draft Mode on,
sanityFetchreads drafts using a server token and asks Sanity for Content Source Maps. sanityFetchencodes the source information into strings as invisible characters, called stega.<VisualEditing />scans the page for those characters and draws a clickable overlay over each piece of text.- Clicking an overlay tells the Studio, through
postMessage, to open that document and field. - As the editor types,
<SanityLive />picks up the change and the page re-renders with the new draft.
Outside Draft Mode, none of this happens. Visitors get clean, published content with no overlays and no invisible characters.
Prerequisites
| Requirement | Detail |
|---|---|
| Next.js | 16.x with the App Router |
next-sanity | 13.x (Sanity's guide asks for 13.1.5 or later) |
| Node.js | 22.12 or later |
| A Viewer token | Created under API > Tokens in your project settings |
| CORS | Your site's origin added with credentials allowed |
Add the CORS origin now so you don't forget:
npx sanity cors add http://localhost:3000 --credentials
This guide assumes a standalone Studio on http://localhost:3333 and a Next.js app on http://localhost:3000. If you embed the Studio in Next.js, the same steps apply, with a couple of simplifications noted along the way.
Step 1: Add Environment Variables
The Next.js app needs three values:
# frontend/.env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=abc123xy
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_STUDIO_URL=http://localhost:3333
SANITY_API_READ_TOKEN=sk...
The token has no NEXT_PUBLIC_ prefix, so Next.js keeps it on the server. defineLive decides when to share it with the browser, which only happens in Draft Mode.
Step 2: Configure the Presentation Tool
In the Studio, add the Presentation tool and tell it where your site is:
// studio/sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { presentationTool } from 'sanity/presentation'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './src/schemaTypes'
import { resolve } from './src/presentation/resolve'
export default defineConfig({
name: 'default',
title: 'My Site',
projectId: 'abc123xy',
dataset: 'production',
plugins: [
structureTool(),
presentationTool({
resolve,
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
visionTool(),
],
schema: { types: schemaTypes },
})
- previewUrl.initial: the site's origin. With an embedded Studio you can leave it out, because the Studio and site share an origin.
- previewMode.enable: the path the Studio calls to turn on Draft Mode. It must match a real route in your app.
Studio environment variables must start with SANITY_STUDIO_ to be available in the Studio bundle.
Map Documents to URLs
The resolve option tells Presentation which pages show which documents. That powers two things: selecting a document navigates the preview to the right page, and documents get links to where they appear:
// studio/src/presentation/resolve.ts
import { defineLocations, type PresentationPluginOptions } from 'sanity/presentation'
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
post: defineLocations({
select: { title: 'title', slug: 'slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
{ title: 'All posts', href: '/' },
],
}),
}),
},
}
The href values must match your Next.js routes. If you rename /posts to /blog, update both.
Step 3: Configure the Client for Stega
On the Next.js side, add stega.studioUrl to the client:
// frontend/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: '2025-02-19',
useCdn: true,
stega: {
studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333',
},
})
studioUrl doesn't switch stega on by itself. It tells the overlays where to send the editor. Without it, overlays appear but clicking them does nothing useful. For an embedded Studio, use the relative path, such as '/studio'.
Step 4: Fetch With defineLive
defineLive wraps the client and handles perspectives, stega and live updates:
// frontend/src/sanity/lib/live.ts
import { defineLive } from 'next-sanity/live'
import { client } from './client'
const token = process.env.SANITY_API_READ_TOKEN
export const { sanityFetch, SanityLive } = defineLive({
client,
serverToken: token,
browserToken: token,
})
Use sanityFetch in your pages. Leave stega at its default for content you render, and turn it off for anything that isn't visible text:
// frontend/src/app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { defineQuery } from 'next-sanity'
import { sanityFetch } from '@/sanity/lib/live'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
_id,
_type,
title,
excerpt,
mainImage
}`)
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const { data } = await sanityFetch({ query: POST_QUERY, params: await params, stega: false })
return { title: data?.title }
}
export default async function PostPage({ params }: Props) {
const { data: post } = await sanityFetch({ query: POST_QUERY, params: await params })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
)
}
The reason for stega: false in metadata is that invisible characters in a <title> or meta description would end up in search results. The broader defineLive setup, including generateStaticParams and Cache Components, is covered in using Sanity with the Next.js App Router.
Step 5: Add the Draft Mode Routes
The enable route is the bridge between the Studio and your site:
// frontend/src/app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/lib/client'
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
})
defineEnableDraftMode validates the secret the Studio sends against Sanity's API, so only someone logged in to your Studio can turn on Draft Mode. It also sets the cookie in a way that works inside a cross-site iframe, including in Safari.
The disable route lets editors leave Draft Mode when they view the site directly:
// frontend/src/app/api/draft-mode/disable/route.ts
import { draftMode } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
;(await draftMode()).disable()
return NextResponse.redirect(new URL('/', request.url))
}
Using request.url as the base keeps the redirect on whichever domain the request came from, rather than a hard-coded localhost.
Step 6: Render the Overlays
Add <VisualEditing /> and a "leave preview" button to the root layout, both only in Draft Mode:
// frontend/src/app/layout.tsx
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { DisableDraftMode } from '@/components/DisableDraftMode'
import { SanityLive } from '@/sanity/lib/live'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled: isDraftMode } = await draftMode()
return (
<html lang="en">
<body>
{children}
<SanityLive />
{isDraftMode && (
<>
<VisualEditing />
<DisableDraftMode />
</>
)}
</body>
</html>
)
}
The button hides itself inside Presentation, where the Studio controls Draft Mode:
// frontend/src/components/DisableDraftMode.tsx
'use client'
import { useIsPresentationTool } from 'next-sanity/hooks'
export function DisableDraftMode() {
const isPresentationTool = useIsPresentationTool()
if (isPresentationTool) return null
return (
<a
href="/api/draft-mode/disable"
className="fixed right-4 bottom-4 rounded-full bg-gray-900 px-4 py-2 text-sm text-white"
>
Disable Draft Mode
</a>
)
}
Run both apps, open the Studio, choose Presentation and your site appears with overlays on every string fetched through sanityFetch.
Step 7: Make Images and Non-Text Content Editable
Stega only works on strings, so images, numbers, booleans and values used in attributes don't get overlays automatically. For those, add a data-sanity attribute with createDataAttribute from next-sanity:
import Image from 'next/image'
import { createDataAttribute } from 'next-sanity'
import { urlFor } from '@/sanity/lib/image'
const attr = createDataAttribute({
id: post._id,
type: post._type,
path: 'mainImage',
baseUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL,
})
<Image
src={urlFor(post.mainImage).width(1200).url()}
alt=""
width={1200}
height={675}
data-sanity={attr.toString()}
/>
The builder can also target nested fields, for example attr('body'), or be scoped with attr.scope('sections') for page builder arrays. This is why the query above returns _id and _type.
Avoid Stega Pitfalls
Invisible characters are harmless when displayed, but they change string values. That breaks code that treats content as data:
- Comparisons:
post.layout === 'wide'fails in Draft Mode. UsestegaClean(post.layout) === 'wide', withstegaCleanimported fromnext-sanity. - URLs and slugs: build
hrefvalues from cleaned strings, or fetch slugs withstega: false. - Attributes: class names,
idvalues and colours from Sanity should be cleaned before use. - Dates and numbers: parse cleaned strings.
During development, <VisualEditing /> accepts an onSuspiciousStega callback that reports stega found in attributes, the document head, scripts and similar unsafe places. It audits the whole DOM, so leave it out of production builds.
Going to Production
Visual Editing usually works locally first time and then breaks in production because of a hard-coded value. Check each of these:
- Studio preview URL: set
SANITY_STUDIO_PREVIEW_URLto your production site in the Studio's hosting environment, then redeploy the Studio. - Studio URL in the client: set
NEXT_PUBLIC_SANITY_STUDIO_URLto the deployed Studio, such ashttps://my-site.sanity.studio. - CORS: add the production site origin with credentials. See securing your Sanity project.
- Token: set
SANITY_API_READ_TOKENin the site's hosting environment. - Framing: if your site sends
X-Frame-Optionsor aframe-ancestorsCSP that blocks the Studio's origin, the iframe will be blank. Allow the Studio origin. - Vercel Deployment Protection: protected preview deployments won't load in the iframe. Sanity's
@sanity/vercel-protection-bypassplugin helps configure Vercel's bypass.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Preview shows published content only | Enable route path doesn't match, or the token is missing |
| No overlays appear | <VisualEditing /> not rendered, or content not fetched with sanityFetch |
| Overlays appear but clicks go nowhere | stega.studioUrl missing or wrong |
| Blank iframe | Wrong previewUrl.initial, framing headers or deployment protection |
| Changes need a manual refresh | <SanityLive /> missing from the layout, or CORS not set for the origin |
| Weird characters in tab titles | Stega left on in generateMetadata |
FAQ: Sanity Visual Editing in Next.js
No. Overlays, drafts and stega only exist in Draft Mode, which is enabled through the Studio's authenticated handshake. Visitors always get clean, published content.
Not strictly. You can keep VisualEditing and the Draft Mode routes and replace sanityFetch with your own fetch helper that sets the perspective, token and stega in Draft Mode. defineLive is simply the least work, and gives you live updates as editors type.
A Viewer token is enough to read drafts and Content Source Maps. The same token can be used as both serverToken and browserToken, but because the browser token reaches the browser in Draft Mode, never use a token with write access.
The Presentation tool is designed for desktop-sized screens, where the Studio and preview sit side by side. The preview iframe itself supports different viewport sizes, so editors can check mobile layouts from a desktop.
Stega only encodes strings, so images need a data-sanity attribute created with createDataAttribute, using the document ID, type and field path. Make sure your query returns _id and _type for the document.
Yes, and it's slightly simpler. You can omit previewUrl.initial because the Studio and site share an origin, and set stega.studioUrl to the relative Studio path, such as /studio.
Conclusion
Sanity Visual Editing in Next.js is a handful of small pieces that each do one job: the Presentation tool frames your site and maps documents to URLs, the Draft Mode route lets the Studio switch your site into preview securely, sanityFetch returns drafts with stega during previews, and <VisualEditing /> turns those encoded strings into click-to-edit overlays. Add data-sanity attributes for images and other non-text content, and turn stega off wherever strings are used as data.
Most problems come from a mismatch between the two sides, such as a wrong path, URL, CORS origin or token, so keep those values in environment variables from the start. Once it's working, editors get a preview that updates as they type, and you can extend it with live preview or explore the rest of the next-sanity toolkit.


