Visual Editing

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

Create a query store

The query store provides these capabilities:

  • createFetcherStore(query, params?, initial?): creates a reactive store for a specific query. The initial parameter 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-dedupe internally).
  • 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

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 to presentation).
  • 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/documents message 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

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:

FrameworkData fetchingLive modeReal-time hook
React (`@sanity/react-loader`)`useQuery()` hook`useLiveMode()` hookWraps `createFetcherStore` with React state
Svelte (`@sanity/svelte-loader`)`useQuery()` function`useLiveMode()` functionWraps `createFetcherStore` with Svelte stores
Next.js (`next-sanity`)`sanityFetch()` server function`<SanityLive />` componentUses 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

Was this page helpful?