Sanity Live Preview in Next.js App Router (Presentation)

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

Sanity's Presentation tool is the cleanest live-preview setup available for editorial teams — editors click any text in the embedded iframe and land directly on the relevant field in Studio. Setting it up against Next.js App Router has a few non-obvious steps; this post captures them.

What Sanity Presentation actually does

Presentation is a tool built into Sanity Studio (imported from sanity/presentation) that embeds your front-end URL in an iframe alongside the Studio editor. On the Next.js side, next-sanity returns draft content with invisible "stega" characters that encode Content Source Map data (document ID, field path, Studio URL). The <VisualEditing /> component reads those encoded strings from the DOM and draws click-to-edit overlays. When an editor clicks a heading in the preview, a postMessage tells Studio to jump to that document field.

This is different from the older preview pattern where you had a separate preview route and a full page reload. With Presentation and the Live Content API, the page re-renders as the editor types, with no reload.

Prerequisites

Sanity's current App Router guide targets:

  • Next.js 16 (App Router)
  • next-sanity v13.1.5 or later (it bundles @sanity/visual-editing)
  • A current Studio (sanity v6)
  • A Sanity API token with Viewer permissions
  • Your front-end origin added as a CORS origin with Allow credentials checked

Install once in the Next.js app:

npm i next-sanity@latest

@sanity/preview-kit and its LiveQueryProvider/useLiveQuery pattern belong to the older setup; for App Router projects use defineLive from next-sanity/live instead.

Step 1 — Configure Presentation in sanity.config.ts

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { presentationTool } from 'sanity/presentation'
 
export default defineConfig({
  // Standalone Studios only expose env vars prefixed with SANITY_STUDIO_
  projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
  dataset: process.env.SANITY_STUDIO_DATASET!,
  plugins: [
    structureTool(),
    presentationTool({
      previewUrl: {
        // The full URL of your Next.js app (required when Studio is a separate app)
        initial:
          process.env.SANITY_STUDIO_PREVIEW_URL ?? 'http://localhost:3000',
        // Presentation calls this route with a generated secret
        // when it enters draft mode
        previewMode: {
          enable: '/api/draft-mode/enable',
        },
      },
    }),
  ],
})

The previewMode.enable path must be a route handler in your Next.js app that turns on Next.js draft mode. Keep the secret out of the config — Presentation generates a short-lived secret, passes it in the URL, and your route validates it server-side.

Add a resolve option with defineLocations (also from sanity/presentation) if you want Studio to map documents to front-end URLs and show location badges.

Step 2 — The draft mode enable route handler

next-sanity ships a helper that does the secret handshake for you:

// app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/client' // your configured sanity client
 
export const { GET } = defineEnableDraftMode({
  // A client with a read (Viewer) token — never a write or deploy token
  client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
})

Things worth knowing:

  1. Under the hood this uses validatePreviewUrl from @sanity/preview-url-secret, which queries your dataset to confirm the secret exists as a sanity.previewUrlSecret document. Presentation creates these secrets automatically, which is why the route needs a token that can read them.
  2. The read token needs the Viewer role. Do not use a write or deploy token here.
  3. If you write your own route instead of using the helper, remember that draftMode() returns a Promise in Next.js 15+ — (await draftMode()).enable().

Step 3 — Add visual-editing overlays to your layout

The VisualEditing component from next-sanity/visual-editing draws the overlays and opens the postMessage channel to Studio. Render it only when draft mode is active. SanityLive (from Step 4) renders on every request:

// app/layout.tsx
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { SanityLive } from '@/sanity/live'
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const { isEnabled } = await draftMode()
 
  return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {isEnabled && <VisualEditing />}
      </body>
    </html>
  )
}

For overlays to link back to Studio, your Sanity client needs stega: { studioUrl: 'https://your-studio-url' } in createClient. Without it the overlays render but clicking them does nothing.

Step 4 — Fetch with defineLive for real-time updates

For real-time field updates as the editor types, replace your client.fetch() calls with the sanityFetch returned by defineLive. Your page components stay Server Components; there's no client-side query provider to wire up.

// sanity/live.ts
import { defineLive } from 'next-sanity/live'
import { client } from './client'
 
export const { sanityFetch, SanityLive } = defineLive({
  client,
  // Server-side fetches of draft content in draft mode
  serverToken: process.env.SANITY_API_READ_TOKEN,
  // Shared with the browser only during draft mode, for live subscriptions
  browserToken: process.env.SANITY_API_READ_TOKEN,
})
// app/posts/[slug]/page.tsx
const { data: post } = await sanityFetch({
  query: POST_QUERY,
  params: await params,
})

sanityFetch switches perspective (published vs drafts), stega encoding, and caching automatically based on draft mode. Pass stega: false in generateMetadata and generateStaticParams so invisible characters don't end up in <title> tags or URL segments.

The browserToken is only sent to the browser during draft mode, which is why it must be a Viewer-only token.

Disabling draft mode

Add a /api/draft-mode/disable route that calls (await draftMode()).disable() and redirects back. Wire an "Exit preview" link to that route, and hide it inside Presentation with the useIsPresentationTool hook from next-sanity/hooks, since Studio controls draft mode there. Having an independent escape hatch for editors who open the preview directly is worth the few extra lines.

Staging vs production

Set SANITY_STUDIO_PREVIEW_URL to the front-end URL you want Studio to preview, and update stega.studioUrl and your CORS origins to the deployed URLs too. Because Vercel preview URLs change per branch, point Presentation at a stable URL (such as a fixed staging domain) rather than a per-deployment URL. If you embed the Studio inside the Next.js app, you can omit initial because the origin is implicit.

One gotcha: if your Next.js app sets a restrictive frame-ancestors CSP, the Presentation iframe will silently blank. Add frame-ancestors 'self' https://your-studio-domain.sanity.studio to the Content-Security-Policy header in next.config.ts. This is a common reason Presentation appears broken on first deploy.


Need Sanity live preview set up for your project?

I work with startups and agencies as a freelance Sanity + Next.js specialist — draft mode, live preview, Presentation tool configuration, and full builds. Fixed-price proposals with milestones, 24-hour response, and a live staging URL from day one. See recent projects and how I work →

Frequently asked questions

What is Sanity live preview / the Presentation tool?

Presentation is a tool built into Sanity Studio that embeds your live front-end in an iframe next to the editor. On the Next.js side, `next-sanity`'s `<VisualEditing />` component attaches click-to-edit overlays on rendered elements, so an editor can click a heading in the preview and jump straight to that field in Studio — with GROQ queries re-running live as they type, no page reload.

What does the @sanity/preview-url-secret package do?

`validatePreviewUrl` from `@sanity/preview-url-secret` checks that the secret token in your draft-mode enable route matches a `sanity.previewUrlSecret` document in your dataset, before it lets a request turn on Next.js draft mode. The Presentation tool creates these secrets for you; `defineEnableDraftMode` from `next-sanity/draft-mode` calls the validation for you. Always validate with a read-only (Viewer) token, never a write or deploy token.

Why does my deployed Sanity Studio show a NEXT_PUBLIC_SANITY_PROJECT_ID / sanity.config.ts deployment error?

This almost always means the env var wasn't available when the Studio was built — `sanity.config.ts` reads the project ID at build time, and if it's undefined the config throws instead of falling back silently. A standalone Studio (deployed with `sanity deploy`) only exposes environment variables prefixed with `SANITY_STUDIO_`, so `NEXT_PUBLIC_SANITY_PROJECT_ID` will be undefined there; use `SANITY_STUDIO_PROJECT_ID` or hardcode the ID. An embedded Studio inside your Next.js app can read `NEXT_PUBLIC_` variables like the rest of the app.