Type something to search...
Using Live Preview With Sanity

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:

FeatureWhat editors seeWhere it runsNeeds a token
Draft previewUnpublished drafts on the real siteYour front end, in Draft ModeYes, a viewer token
Presentation toolThe draft preview inside the Studio, with click-to-edit overlaysStudio iframeYes
Live published contentPublished changes appear for every visitor without a rebuildYour production siteNo, for public datasets
Studio list and form previewsTitles and thumbnails in the StudioStudio onlyNo

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.io with useCdn: 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-loader use 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, never NEXT_PUBLIC_....
  • Gate the browser token behind Draft Mode: defineLive only sends browserToken to 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-sanity v13, <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-sanity supports a Sync Tag Invalidate Function with waitFor="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

SymptomLikely causeFix
Preview shows published content onlyDraft Mode isn't enabled, or the token is missingCheck the enable route and SANITY_API_READ_TOKEN
Empty results in previewQuery sent with drafts to the CDN, or a private dataset without a tokenUse useCdn: false for drafts and add a token
No live updates, CORS warning in consoleFront-end origin missing from CORS originsAdd it in sanity.io/manage with credentials allowed
Garbled page titles in previewStega strings in metadataPass stega: false in generateMetadata
Studio reloads unexpectedlySanityLive rendered on the embedded Studio routeRender it only in content layouts
Updates appear only after a refreshDefault stale-while-revalidate behaviourAccept it, or use the invalidate function and waitFor
String comparisons fail in Draft ModeStega 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.

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