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-sanityv13.1.5 or later (it bundles@sanity/visual-editing)- A current Studio (
sanityv6) - 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:
- Under the hood this uses
validatePreviewUrlfrom@sanity/preview-url-secret, which queries your dataset to confirm the secret exists as asanity.previewUrlSecretdocument. Presentation creates these secrets automatically, which is why the route needs a token that can read them. - The read token needs the Viewer role. Do not use a write or deploy token here.
- 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
01What 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.
02What 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.
03Why 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.