Type something to search...
Setting Up Sanity Visual Editing in Next.js

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:

  1. The Studio loads your Next.js site in an iframe, using the URL from previewUrl.
  2. 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.
  3. With Draft Mode on, sanityFetch reads drafts using a server token and asks Sanity for Content Source Maps.
  4. sanityFetch encodes the source information into strings as invisible characters, called stega.
  5. <VisualEditing /> scans the page for those characters and draws a clickable overlay over each piece of text.
  6. Clicking an overlay tells the Studio, through postMessage, to open that document and field.
  7. 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

RequirementDetail
Next.js16.x with the App Router
next-sanity13.x (Sanity's guide asks for 13.1.5 or later)
Node.js22.12 or later
A Viewer tokenCreated under API > Tokens in your project settings
CORSYour 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. Use stegaClean(post.layout) === 'wide', with stegaClean imported from next-sanity.
  • URLs and slugs: build href values from cleaned strings, or fetch slugs with stega: false.
  • Attributes: class names, id values 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:

  1. Studio preview URL: set SANITY_STUDIO_PREVIEW_URL to your production site in the Studio's hosting environment, then redeploy the Studio.
  2. Studio URL in the client: set NEXT_PUBLIC_SANITY_STUDIO_URL to the deployed Studio, such as https://my-site.sanity.studio.
  3. CORS: add the production site origin with credentials. See securing your Sanity project.
  4. Token: set SANITY_API_READ_TOKEN in the site's hosting environment.
  5. Framing: if your site sends X-Frame-Options or a frame-ancestors CSP that blocks the Studio's origin, the iframe will be blank. Allow the Studio origin.
  6. Vercel Deployment Protection: protected preview deployments won't load in the iframe. Sanity's @sanity/vercel-protection-bypass plugin helps configure Vercel's bypass.

Troubleshooting

SymptomLikely cause
Preview shows published content onlyEnable 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 nowherestega.studioUrl missing or wrong
Blank iframeWrong 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 titlesStega 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper