Listening to content updates with @sanity/client
Learn how to receive real-time content updates using the Sanity JavaScript client, with the Live Content API and query listeners.
Whether you are building a live dashboard, a preview environment for editors, or a site that stays current without manual redeployment, the Sanity JavaScript client gives you two approaches for keeping content in sync: the Live Content API and query listeners.
The Live Content API is the recommended approach for most applications. It scales well, works with CDN caching, and is designed for production use. Query listeners provide a lower-level event stream that includes mutation details and previous document revisions.
Prerequisites
This guide assumes you have already installed and configured @sanity/client. See Getting started with @sanity/client for setup instructions.
Real-time updates with the Live Content API
The Live Content API uses a tag-based system to notify your application when relevant content changes. Instead of streaming full documents on every mutation, it sends lightweight sync tag events. Your application stores these tags and uses them to determine when to refetch data.
This approach works well with CDN caching and scales to high traffic volumes. If you are building a website or application that displays content to end users, use the Live Content API.
Using a framework like Next.js?
The next-sanity library offers a turnkey integration with the Live Content API that handles sync tags, revalidation, and draft mode automatically.
How it works
The Live Content API follows a three-step pattern:
- Fetch content with
client.fetch()and store the sync tags returned in the response. - Subscribe to live events using
client.live.events(). - When an event arrives whose tags match your stored tags, refetch the content to get the latest version.
The following example demonstrates this pattern. It fetches a single document, stores the sync tags, and refetches whenever a matching live event arrives.
import {createClient} from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'your-dataset-name',
apiVersion: '2026-03-01',
useCdn: true,
})
const query = '*[_type == "post" && slug.current == $slug][0]'
const params = {slug: 'hello-world'}
// Store sync tags from the initial fetch
let syncTags: string[] = []
async function render(lastLiveEventId?: string) {
const response = await client.fetch(query, params, {
// Required: returns the full response object including syncTags
filterResponse: false,
lastLiveEventId,
})
syncTags = response.syncTags
const data = response.result
console.log(data)
}
// Initial fetch
render()
// Subscribe to live events
const subscription = client.live.events().subscribe({
next: (event) => {
if (
event.type === 'message' &&
event.tags.some((tag) => syncTags.includes(tag))
) {
// A matching tag means our content changed, so refetch
render(event.id)
}
if (event.type === 'restart') {
// Restart events mean we should refetch without an event ID
render()
}
},
error: (err) => {
console.error('Live event stream error:', err)
},
})
// Unsubscribe when no longer needed
// subscription.unsubscribe()Setting filterResponse: false is essential. Without it, fetch() returns only the query result and you will not receive the syncTags needed to connect your fetched content to the live event stream.
Event types
The client.live.events() method returns an Observable that emits the following event types:
message: Carries sync tags that you compare against your stored tags. If any match, your content has changed.restart: The event stream has reset. Refetch all content without passing alastLiveEventId.welcome: Connection established successfully.reconnect: The client reconnected after a temporary disconnection.goaway: The connection was rejected, for example because connection limits were reached. Consider falling back to polling.
Listening for draft changes
To receive live updates for draft content, pass includeDrafts: true to the events() method. The client must be configured with an authentication token that has at minimum a viewer role.
const client = createClient({
projectId: 'your-project-id',
dataset: 'your-dataset-name',
apiVersion: '2026-03-01',
useCdn: false,
token: process.env.SANITY_API_TOKEN,
})
const subscription = client.live
.events({includeDrafts: true})
.subscribe((event) => {
if (event.type === 'message') {
// Check tags and refetch as needed
}
})Listening to queries with client.listen()
The listen() method opens a server-sent event (SSE) stream that notifies your application whenever documents matching a GROQ query are created, updated, or deleted. Unlike the Live Content API, listeners give you direct access to the mutation data and the affected document, making them useful for backend workflows and editorial tools.
For frontend applications serving content to end users, the Live Content API is a better fit. It handles CDN caching efficiently and scales to high connection counts. Use client.listen() when you need mutation-level detail or are building server-side processes that react to content changes.
const query = '*[_type == "comment" && authorId != $ownerId]'
const params = {ownerId: 'bikeOwnerUserId'}
const subscription = client.listen(query, params).subscribe((update) => {
const comment = update.result
console.log(`${comment.author} commented: ${comment.text}`)
})
// Unsubscribe when no longer needed
subscription.unsubscribe()The listen() method returns an Observable. Call .subscribe() to start receiving events, and .unsubscribe() to stop. By default, each event includes a result property with the document after the mutation is applied. For delete mutations, result is not present.
Understanding listener events
Each event includes a transition field that tells you what happened:
appear: A document now matches the query, either because it was created or updated to match.update: An already-matching document was modified.disappear: A document no longer matches, either because it was deleted or modified to fall out of scope.
client.listen('*[_type == "post"]').subscribe((update) => {
switch (update.transition) {
case 'appear':
console.log('New post:', update.result)
break
case 'update':
console.log('Post updated:', update.result)
break
case 'disappear':
console.log('Post removed:', update.documentId)
break
}
})Listener options
The third argument to listen() accepts several options that control what data each event includes.
const subscription = client.listen(
'*[_type == "post"]',
{},
{
// Include the document before the mutation was applied
includePreviousRevision: true,
// Include the raw mutations that caused the change
includeMutations: true,
// Set to false to omit the result document (saves bandwidth)
includeResult: true,
// Control visibility: 'query' (default), 'sync', or 'async'
visibility: 'query',
// Filter which event types to receive
events: ['welcome', 'mutation', 'reconnect'],
// Tag for request logs
tag: 'post-listener',
}
).subscribe((update) => {
if (update.previous) {
console.log('Before:', update.previous.title)
console.log('After:', update.result.title)
}
})Setting includeResult: false reduces bandwidth when you only need to know that a change occurred. Combining includePreviousRevision: true with includeMutations: true gives you a complete before-and-after picture along with the specific operations that were applied.
Choosing between the Live Content API and listeners
Use the Live Content API when you are building a website or application that displays content to users. It works with the CDN, scales efficiently, and integrates with framework-specific libraries like next-sanity. If you need the content on screen to update when an editor publishes, this is the right choice.
Use client.listen() when you need detailed mutation data, previous document revisions, or are building server-side automation such as triggering external systems on content changes. Listeners give you fine-grained control over the event stream at the cost of managing more complexity yourself.
Common issues
Sync tags are undefined or empty. Make sure you pass filterResponse: false to client.fetch(). Without this option, the response only contains the query result and the sync tags are not included.
Live events are not arriving. Verify that your API version is 2021-03-25 or later and that your frontend domain is listed in the project's CORS origins at sanity.io/manage.
Listener does not return draft documents. Draft documents are only visible to authenticated clients. Pass a token when creating the client and set useCdn: false.
Receiving a goaway event. This means the live connection was closed, usually because connection limits were reached. Implement a polling fallback: periodically call client.fetch() on a timer instead of relying on the event stream.
Next steps
- Read the Live Content API overview to understand how sync tags, caching, and usage limits work together.
- Follow the live content guide for framework-specific setup, including Next.js with next-sanity.
- Explore the Live Content API reference for the full HTTP API details and event stream specification.
- Browse the live content examples on GitHub for custom integration patterns beyond Next.js.