Live preview content updates
Build real-time content updates using the core-loader, Fetcher interface, enableLiveMode(), and the Live Content API for progressive enhancement.
Real-time content updates let editors see their changes reflected in the preview as they type, without manually refreshing the page. This is powered by a combination of the Live Content API, the Comlink messaging protocol, and the core loader's fetcher system.
This guide explains how to implement real-time updates in a custom integration, covering both the high-level approach using @sanity/core-loader and the lower-level approach using @sanity/client directly.
How real-time updates work
When an editor changes content in the Studio while the Presentation Tool is open, the update reaches your frontend through one of two paths:
Path 1: via the Presentation Tool (live mode)
- The editor changes a field in the Studio.
- The Presentation Tool detects the mutation and sends updated query results to your frontend via Comlink (
postMessage). - Your frontend's data layer receives the new data and re-renders.
This path provides the fastest updates because the Presentation Tool already has the query results. It requires your frontend to be running inside the Presentation Tool iframe.
Path 2: via the Live Content API (direct)
- The editor publishes content (or saves a draft).
- The Content Lake emits sync tags identifying which queries are affected.
- Your frontend receives the sync tags and re-fetches the affected queries.
- The page re-renders with fresh data.
This path works both inside and outside the Presentation Tool. It's the mechanism that framework libraries like next-sanity use for production real-time updates via <SanityLive />.
Using @sanity/core-loader
The core loader provides a framework-agnostic abstraction for data fetching with built-in live mode support. It manages query stores, caching, deduplication, and the live mode connection.
Install
npm install @sanity/core-loader @sanity/client
pnpm add @sanity/core-loader @sanity/client
yarn add @sanity/core-loader @sanity/client
bun add @sanity/core-loader @sanity/client
Create a query store
import { createQueryStore } from '@sanity/core-loader'
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-12-01',
useCdn: true,
stega: {
enabled: true,
studioUrl: 'https://your-studio.sanity.studio',
},
})
export const queryStore = createQueryStore({ client })The query store provides these capabilities:
createFetcherStore(query, params?, initial?): creates a reactive store for a specific query. Theinitialparameter accepts pre-fetched data for SSR hydration. The store fetches data when subscribed to and updates automatically in live mode.enableLiveMode(options): activates real-time updates via the Presentation Tool's Comlink connection.setServerClient(client): configures the server-side client (for SSR mode only; throws if called in the browser).
The Fetcher interface
The core loader's architecture is built around a Fetcher interface with two methods:
interface Fetcher {
hydrate(query, params, initial?): QueryStoreState // Sync: returns initial state
fetch(query, params, store, controller): void // Async: triggers data loading
}There are two fetcher implementations:
- Default fetcher: fetches from the Sanity API with caching and deduplication (using
async-cache-dedupeinternally). - Live mode fetcher: receives query results from the Studio via Comlink.
The key architectural insight is the hot-swap mechanism: when live mode connects, it replaces the default fetcher with the live fetcher. All existing query stores automatically switch to receiving updates from the Studio. When live mode disconnects, the original fetcher is restored and stores resume normal API fetching. This swap is powered by a reactive atom (from nanostores) that all stores subscribe to.
Fetch data with a query store
Each query gets its own reactive store:
import { queryStore } from './lib/query-store'
// Create a store for a specific query
const postStore = queryStore.createFetcherStore(
'*[_type == "post" && slug.current == $slug][0]',
{ slug: 'my-post' }
)
// Subscribe to the store to trigger fetching and receive updates.
// The state includes data, loading, error, sourceMap, and perspective.
const unsubscribe = postStore.subscribe((state) => {
if (state.loading) {
// Show loading indicator
return
}
if (state.error) {
console.error('Query failed:', state.error)
return
}
// Render the data
renderPost(state.data)
// state.sourceMap contains the Content Source Map (for data attributes)
// state.perspective indicates which perspective was used for this result
})
// Unsubscribe when done (stops fetching and live updates for this query)
unsubscribe()The store is lazy: it only fetches data when it has at least one subscriber. When all subscribers unsubscribe, the store stops updating.
Enable live mode
Live mode connects your query stores to the Presentation Tool for real-time updates.
Important
if you're using enableVisualEditing() from @sanity/visual-editing (see enabling overlays), it already manages the live mode Comlink channel internally. Do not also call enableLiveMode(). Doing so creates duplicate Comlink nodes with the same name, which breaks the Presentation Tool handshake. Use enableLiveMode() only if you need live updates without the overlay system.
import { queryStore } from './lib/query-store'
// Only call enableLiveMode() if you are NOT using enableVisualEditing().
// enableVisualEditing() already handles the live mode channel internally.
const disableLiveMode = queryStore.enableLiveMode({
// Called when the Studio changes perspective (for example, when an editor
// switches to a different content release). The perspective may be a string
// like 'drafts' or a stacked array like ['summer-drop', 'drafts', 'published'].
onPerspective: (perspective) => {
console.log('Perspective changed to:', perspective)
// Update your client config to match the new perspective.
// This ensures subsequent server-side fetches use the same perspective.
},
// Optional: called when the Comlink connection is established
onConnect: () => {
console.log('Connected to Presentation Tool')
},
// Optional: called when the connection drops
onDisconnect: () => {
console.log('Disconnected from Presentation Tool')
},
})
// Later, to disable:
disableLiveMode()When live mode activates, it:
- Lazy-loads the live mode module (including
@sanity/comlink). - Establishes a Comlink connection with the Presentation Tool (node name
loaders, connecting topresentation). - Swaps the internal fetcher from the default (API-based) to a live fetcher (Comlink-based).
- All existing query stores automatically switch to receiving updates from the Studio.
- Sends a heartbeat every 10 seconds per active query to keep subscriptions alive.
- Reports which documents are on the current page by sending a
loader/documentsmessage to the Studio after each update. This enables Studio-side features like showing which documents are visible in the preview.
When live mode is disabled (or the connection drops), the fetcher swaps back to the default API-based fetcher, and query stores resume normal fetching.
Server-side rendering
For SSR, create the query store with ssr: true and set the server client before rendering:
// Server-side setup
import { createQueryStore } from '@sanity/core-loader'
// When ssr is true, pass client: false (passing a client throws an error)
const queryStore = createQueryStore({
client: false,
ssr: true,
})
// Set the client before handling requests
queryStore.setServerClient(client)The setServerClient function can only be called in server environments (it throws if called in the browser). It configures the client used for initial data fetching during SSR. On the client side, enableLiveMode takes over for real-time updates.
Encoding data attributes from query results
The core loader exports encodeDataAttribute and defineEncodeDataAttribute at @sanity/core-loader/encode-data-attribute. These functions bridge data fetching and visual editing overlays by creating data-sanity attribute values from Content Source Maps:
import { defineEncodeDataAttribute } from '@sanity/core-loader/encode-data-attribute'
// After fetching data with a query store
postStore.subscribe((state) => {
if (!state.data || !state.sourceMap) return
// Create an encoder scoped to this query result
const encode = defineEncodeDataAttribute(
state.data,
state.sourceMap,
'https://your-studio.sanity.studio'
)
// Encode a specific field path as a data-sanity attribute value.
// Returns string | undefined (undefined if the path has no source mapping).
const titleAttr = encode('title')
const imageAttr = encode('mainImage')
// Scope to a nested path
const bodyEncode = encode.scope('body')
const firstBlockAttr = bodyEncode([0, 'children', 0, 'text'])
// Apply to DOM elements (guard against undefined)
if (titleAttr) {
document.querySelector('.post-title')?.setAttribute('data-sanity', titleAttr)
}
if (imageAttr) {
document.querySelector('.post-image')?.setAttribute('data-sanity', imageAttr)
}
})This is particularly useful for non-string content (images, numbers) that can't carry stega encoding. The encoder uses the Content Source Map to resolve the source document and field path for any value in the query result.
Using the Listener API directly
If you don't need the query store abstraction, you can implement real-time updates using the Sanity client's Listener API (client.listen()). This is a mutation-based subscription that notifies you when documents change:
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-12-01',
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
})
// Listen for mutations on specific document types
const subscription = client
.listen('*[_type in $types]', { types: ['post', 'page'] })
.subscribe((update) => {
if (update.type === 'mutation') {
// A document was created, updated, or deleted.
// Re-fetch the affected content.
refetchContent(update.documentId)
}
})
// Clean up when done
subscription.unsubscribe()Note
client.listen() (the Listener API) and the Live Content API are different mechanisms. The Listener API subscribes to individual document mutations and requires an API token. The Live Content API (used by framework libraries like next-sanity's <SanityLive />) uses sync tags to efficiently invalidate cached queries and works in production without exposing tokens to the client. The core loader's live mode uses neither of these directly; it receives pre-computed query results from the Studio via Comlink.
This approach gives you full control but requires you to manage:
- Query deduplication: multiple components may need the same data.
- Cache invalidation: deciding which queries to re-fetch when a document changes.
- Connection lifecycle: handling reconnections and cleanup.
- Stega encoding: applying Content Source Maps to re-fetched data if overlays are active.
The core loader handles all of these concerns automatically.
Integrating with the refresh callback
The overlay system's refresh callback (from enableVisualEditing()) complements real-time updates. While live mode pushes new data to your stores, the refresh callback handles cases where the Studio explicitly requests a refresh:
import { enableVisualEditing } from '@sanity/visual-editing'
import { queryStore } from './lib/query-store'
// Enable overlays with refresh handling
enableVisualEditing({
// Return false (synchronous) to use default behavior,
// or Promise<void> for async refresh operations.
refresh: (payload) => {
if (payload.source === 'mutation') {
// The Studio edited a document. If live mode is active,
// the query stores update automatically. This callback
// handles any additional refresh logic your app needs.
return updateUI() // Returns Promise<void>
}
if (payload.source === 'manual') {
// The editor clicked the refresh button.
// Force a full re-fetch of all data.
window.location.reload()
return new Promise(() => {}) // Never resolves (page is reloading)
}
return false // Use default behavior
},
})
// Enable live mode for automatic query updates
queryStore.enableLiveMode({})When both live mode and the refresh callback are active, live mode handles the data updates while the refresh callback handles UI-level concerns (animations, scroll position, loading states).
How framework libraries handle this
Framework libraries build on @sanity/core-loader to provide framework-idiomatic APIs:
| Framework | Data fetching | Live mode | Real-time hook |
|---|---|---|---|
| React (`@sanity/react-loader`) | `useQuery()` hook | `useLiveMode()` hook | Wraps `createFetcherStore` with React state |
| Svelte (`@sanity/svelte-loader`) | `useQuery()` function | `useLiveMode()` function | Wraps `createFetcherStore` with Svelte stores |
| Next.js (`next-sanity`) | `sanityFetch()` server function | `<SanityLive />` component | Uses Live Content API with sync tags |
All framework loaders follow the same pattern:
- Call
createQueryStore()with a framework-specific tag. - Wrap the core store's reactive primitives (nanostores) in framework-native equivalents (React hooks, Svelte stores).
- Add a
loadQuery()function for server-side data loading. - Export a pre-configured default instance with
ssr: true.
Troubleshooting
Live updates work in the Presentation Tool but not in production
Live mode via @sanity/core-loader only works inside the Presentation Tool iframe because it relies on Comlink (postMessage) to receive updates from the Studio. For production real-time updates, use client.listen() for mutation-based subscriptions, or a framework library's production live component (like <SanityLive /> in next-sanity) for the sync-tag-based Live Content API.
Updates are delayed or inconsistent
- Check the heartbeat: live mode sends a heartbeat every 10 seconds per query. If the Presentation Tool doesn't receive heartbeats, it stops sending updates for that query.
- Check eventual consistency: the Content Lake is eventually consistent. After a mutation, there may be a brief delay before queries return the updated data. The overlay system's refresh mechanism accounts for this with an automatic second refresh after one second.
Query stores don't update when live mode activates
- Check subscription timing: query stores are lazy. They only fetch (and receive live updates) when they have at least one subscriber. Make sure you call
.subscribe()before enabling live mode. - Check the Comlink connection: live mode requires a successful Comlink handshake with the Presentation Tool. Check the browser console for connection errors.
Data appears without stega encoding in live mode
- Check client configuration: the core loader applies stega encoding to live mode results only if the client has
stega: { enabled: true }. Verify your client configuration.
Live mode breaks when the Studio is embedded in the same app
If your Sanity Studio is embedded as a route inside the same frontend application (for example, /studio mounted alongside your content routes), do not call enableLiveMode() from your root or layout component. The embedded Studio runs its own Comlink connections, and a top-level enableLiveMode() call attaches them to the Studio's own iframe context, conflicting with the Studio's internal handshake.
Use dedicated layouts to keep the Studio route isolated from live mode initialization. Only call enableLiveMode() on the content routes that the Presentation Tool previews.
Next steps
- Architecture overview: understand how overlays fit into the broader visual editing system
- Setting up the Sanity client for visual editing: configure stega encoding that powers automatic overlay detection
- Enabling overlays and click-to-edit: add the refresh callback that complements live updates
- Configuring the Presentation Tool: set up the Studio plugin that hosts the preview iframe