
Using Live Preview With Sanity
Live preview in Sanity means editors see unpublished changes rendered on the real front end, updating within a second or so of each keystroke, without saving, publishing or refreshing the page. You build it from three pieces: an authenticated client that queries with the drafts perspective, a way to switch a browser session into preview (Draft Mode in Next.js), and a real-time channel that tells the page when content has changed. In Next.js, defineLive from next-sanity/live gives you all three: a sanityFetch helper and a <SanityLive /> component that use the Live Content API to refetch only the queries affected by an edit.
Previews matter because a form in the Studio never tells the whole story. Editors want to know whether a headline wraps badly, whether an image crop works on mobile and whether a new section looks right next to the old ones. This article explains the kinds of preview Sanity supports, the building blocks underneath, a complete Next.js App Router setup, how to do the same in other frameworks, how to keep tokens safe and how to fix the problems you're most likely to hit.
What Live Preview Means in Sanity
"Preview" covers a few different features, and it helps to separate them before writing any code:
| Feature | What editors see | Where it runs | Needs a token |
|---|---|---|---|
| Draft preview | Unpublished drafts on the real site | Your front end, in Draft Mode | Yes, a viewer token |
| Presentation tool | The draft preview inside the Studio, with click-to-edit overlays | Studio iframe | Yes |
| Live published content | Published changes appear for every visitor without a rebuild | Your production site | No, for public datasets |
| Studio list and form previews | Titles and thumbnails in the Studio | Studio only | No |
This post focuses on the first three, which share the same machinery. List previews are a different topic, covered in list previews. Click-to-edit overlays build on live preview and are covered in visual editing with Next.js.
The Building Blocks
Drafts and the Drafts Perspective
When an editor changes a published document, the Studio writes to a separate draft with the ID drafts.<id>. Public queries never see it. To render drafts, your preview query must use the drafts perspective, which returns the draft where one exists and the published version otherwise. Perspectives are explained in Sanity Content Lake explained.
Two rules follow from that:
- Drafts need authentication: anything with a dot in its ID is private, so preview requests need a token with at least viewer access.
- Drafts bypass the CDN: the API CDN only serves published and raw content, so preview requests go to
api.sanity.iowithuseCdn: false.
A Preview Session
Your site needs to know when a request comes from an editor rather than a visitor. In Next.js this is Draft Mode, a signed cookie that draftMode() from next/headers can enable, disable and check. Other frameworks use a similar cookie or a query parameter checked on the server.
A Real-Time Channel
There are two ways for a page to learn that content changed:
- The Live Content API: the Content Lake returns opaque sync tags with every query response. A live connection streams events containing tags, and when an event matches a tag your page used, you refetch that query. This works for published content in production and for drafts when the connection has a token.
- The Presentation tool: when your site runs inside the Studio's Presentation iframe, the Studio can push updated query results straight to the page over
postMessage. Framework loaders such as@sanity/react-loaderuse this path.
The Live Content API is available on all plans and requires API version 2021-03-25 or later. It doesn't support dataset aliases, so always use the real dataset name.
Live Preview in the Next.js App Router
next-sanity v13 wraps all of this for the App Router. It requires Next.js 16 and React 19.2.3 or later, and the live features don't support the Pages Router. Install it in your Next.js project:
npm install next-sanity@latest
Step 1: Configure the Client
// 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: '2026-10-01',
useCdn: true,
stega: {
studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL ?? 'http://localhost:3333',
},
})
The stega option only matters if you add visual editing later. It embeds invisible source information in strings during Draft Mode and is ignored otherwise.
Step 2: Create the Live Helpers
// src/sanity/lib/live.ts
import { defineLive } from 'next-sanity/live'
import { client } from './client'
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
export const { sanityFetch, SanityLive } = defineLive({
client,
serverToken: token,
browserToken: token,
})
Create the token in your project's API settings at sanity.io/manage with the Viewer role. The serverToken is used for draft queries on the server. The browserToken is only sent to the browser when Draft Mode is enabled from the Presentation tool or the Vercel Toolbar, so it can receive live draft events. Both can be the same viewer token, and neither should have write access. If you don't want draft previews at all, set both to false.
Step 3: Add Draft Mode Routes
The enable route verifies that the request came from a logged-in Studio session before switching Draft Mode on:
// 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 }),
})
And a route to leave preview:
// 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))
}
Step 4: Render SanityLive in the Layout
// src/app/layout.tsx
import { draftMode } from 'next/headers'
import { VisualEditing } from 'next-sanity/visual-editing'
import { SanityLive } from '@/sanity/lib/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>
)
}
<SanityLive /> renders for everyone. For normal visitors it listens for published changes and revalidates affected pages. In Draft Mode it switches to draft events. <VisualEditing /> is optional and adds the overlays. If you embed the Studio in the same app, render both components only in your content layouts, not on the Studio route, or the Studio may reload unexpectedly. Embedding Sanity Studio in Next.js shows how to split the layouts.
Step 5: Fetch With sanityFetch
// src/app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { defineQuery } from 'next-sanity/groq'
import { sanityFetch } from '@/sanity/lib/live'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
excerpt,
body
}`)
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const { data: post } = await sanityFetch({ query: POST_QUERY, params: { slug } })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
)
}
You don't choose a perspective here. sanityFetch checks Draft Mode itself: outside it you get cached, published content, and inside it you get drafts from the uncached API with the server token. In generateMetadata and generateStaticParams, pass stega: false so invisible characters never reach <title> tags or URL segments, and use perspective: 'published' in generateStaticParams so drafts don't create routes.
One version caveat: these examples assume cacheComponents is off in next.config.ts. New apps from recent create-next-app releases turn it on, and sanityFetch then has to run inside a use cache boundary. The next-sanity docs include a separate guide for Cache Components. The wider integration is covered in next-sanity: the complete guide.
Opening the Preview From the Studio
Editors shouldn't need to visit /api/draft-mode/enable by hand. The Presentation tool in the Studio loads your site in an iframe, calls the enable route with a secret, and keeps the preview in sync as they edit:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { presentationTool } from 'sanity/presentation'
import { schemaTypes } from './schemaTypes'
export default defineConfig({
projectId: 'abc123xy',
dataset: 'production',
plugins: [
structureTool(),
presentationTool({
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_URL ?? 'http://localhost:3000',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
schema: { types: schemaTypes },
})
Add your front-end origin, such as http://localhost:3000, to the project's CORS origins with credentials allowed, or the browser can't open the live connection. Document locations, which map documents to URLs, and overlays are covered in visual editing with Next.js.
Live Preview Without Next.js
Outside Next.js you have two options.
Use a framework loader. @sanity/react-loader and @sanity/svelte-loader provide useQuery() and useLiveMode(). Live mode receives results from the Presentation tool, so it only works while the site is inside the Studio iframe. That's fine for editor previews, and the framework guides for SvelteKit, Nuxt and Astro show the wiring.
Use the Live Content API directly. @sanity/client exposes client.live.events(). Store the sync tags from each response, then refetch when a matching event arrives:
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'abc123xy',
dataset: 'production',
apiVersion: '2026-10-01',
useCdn: true,
})
const query = `*[_type == "post" && slug.current == $slug][0]{ title, excerpt }`
const params = { slug: 'hello-world' }
let syncTags: string[] = []
async function render(lastLiveEventId?: string) {
const res = await client.fetch(query, params, {
filterResponse: false,
lastLiveEventId,
})
syncTags = res.syncTags ?? []
console.log(res.result)
}
render()
const subscription = client.live.events().subscribe({
next: (event) => {
if (event.type === 'message' && event.tags.some((tag) => syncTags.includes(tag))) {
render(event.id)
}
if (event.type === 'restart') {
render()
}
},
error: (err) => console.error('Live connection failed', err),
})
// Later: subscription.unsubscribe()
Passing lastLiveEventId tells the CDN to return a response at least as fresh as that event, so you don't refetch stale cached data. For drafts, use a client with a viewer token, useCdn: false and perspective: 'drafts', and only do that on the server or for authenticated editors.
Listening to Draft Changes in Scripts
client.listen() is a third mechanism, and it's often confused with the Live Content API. It streams individual mutation events for documents matching a filter, including drafts when the token can read them:
const subscription = client
.withConfig({ token: process.env.SANITY_API_READ_TOKEN, useCdn: false })
.listen(`*[_type == "post"]`, {}, { includeResult: false })
.subscribe((update) => {
console.log(update.transition, update.documentId)
})
Listeners suit internal tools, dashboards and scripts. For websites, prefer the Live Content API: its events never contain document IDs or content, and it is designed to scale to many visitors. Concurrent listeners per dataset are limited by plan, and each connection is dropped after about four hours.
Keeping Tokens Safe
A preview token can read every draft in the dataset, so treat it as a secret:
- Use the Viewer role: preview never needs write access.
- Keep it out of public variables: name it
SANITY_API_READ_TOKEN, neverNEXT_PUBLIC_.... - Gate the browser token behind Draft Mode:
defineLiveonly sendsbrowserTokento sessions that the Studio enabled. - Don't put tokens in Studio variables: anything prefixed
SANITY_STUDIO_is written into the Studio bundle.
For a private dataset, published queries also need a token. Put a viewer token on the client you pass to defineLive, set browserToken: false, and the browser still receives published events without any token, because events only contain opaque tags. More on access control is in Sanity roles and permissions and securing your Sanity project.
Performance and Caching
Live preview should cost very little for normal visitors:
- Published queries stay on the CDN: only Draft Mode sessions hit the uncached API.
- Live connections don't fetch data: a connection only receives tags. New requests happen when tags match, and those count toward your API quota.
- Revalidation is eventually consistent by default: in
next-sanityv13,<SanityLive>revalidates in the background, so some visitors briefly see the previous version. If every visitor must see updates immediately, or you have a CDN in front of Next.js,next-sanitysupports a Sync Tag Invalidate Function withwaitFor="function".
How caching, tags and the CDN interact is covered in Sanity CDN vs live API and caching and performance. If you rebuild static sites instead, see webhook rebuilds.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Preview shows published content only | Draft Mode isn't enabled, or the token is missing | Check the enable route and SANITY_API_READ_TOKEN |
| Empty results in preview | Query sent with drafts to the CDN, or a private dataset without a token | Use useCdn: false for drafts and add a token |
| No live updates, CORS warning in console | Front-end origin missing from CORS origins | Add it in sanity.io/manage with credentials allowed |
| Garbled page titles in preview | Stega strings in metadata | Pass stega: false in generateMetadata |
| Studio reloads unexpectedly | SanityLive rendered on the embedded Studio route | Render it only in content layouts |
| Updates appear only after a refresh | Default stale-while-revalidate behaviour | Accept it, or use the invalidate function and waitFor |
| String comparisons fail in Draft Mode | Stega characters in values like "center" | Clean them with stegaClean() from next-sanity |
FAQ: Sanity Live Preview
Yes. Drafts, perspectives, the Presentation tool and the Live Content API are all available on the Free plan. Usage limits apply to API requests and live connections, so check the pricing page for the current numbers.
The Live Content API sends opaque sync tags that tell your site which cached queries to refetch, and it is designed for websites with many visitors. client.listen streams individual mutation events with document details and suits scripts and internal tools.
No. You can enable Draft Mode with a preview link and see live drafts in a normal browser tab. The Presentation tool adds a side-by-side view in the Studio, automatic Draft Mode and click-to-edit overlays, so most teams use it.
Usually the production front-end origin is missing from your CORS origins, the read token isn't set in the hosting environment, or the Presentation tool still points at localhost. Check all three after each new deployment URL.
Only a viewer token, and only to editors in Draft Mode. defineLive sends the browserToken only when the Studio enabled Draft Mode for that session. Never expose a token with write access.
The live features of next-sanity, including defineLive and SanityLive, require the App Router. Pages Router projects can still preview drafts with a token and the drafts perspective, but without the built-in live updates.
Conclusion
Live preview in Sanity comes down to three things: query drafts with an authenticated client and the drafts perspective, switch the browser into a preview session, and listen for changes. In Next.js, defineLive, defineEnableDraftMode and the Presentation tool handle each of those with a few small files, while client.live.events() and the framework loaders cover other stacks.
Start with a viewer token, Draft Mode routes and <SanityLive />, then add the Presentation tool so editors can preview without leaving the Studio. Keep tokens on the server, keep published traffic on the CDN, and your editors get instant feedback without slowing the site down for anyone else.


