Relay live draft updates through your server
Keep your Sanity token on the server and relay Live Content API draft updates to the browser in a Next.js app, with no token in client-side code.
Live previews of draft content need a token on the Live Content API connection. In a Next.js app, the browserToken option of defineLive sends that token to the browser. If you can't expose a token client-side, keep it on the server. Listen for live events in a route handler and forward a bare "content changed" signal to the browser. The browser then reruns your server components with router.refresh(). The server components refetch with the server-side token, so no credentials or sync tags reach the browser.
This guide shows how to relay live draft updates in a Next.js App Router app so the token never leaves the server.
You don't need a relay for published content
The Live Content API accepts connections without a token, including on private datasets, and SanityLive handles published updates on its own. See Use live content with a private dataset.
Prerequisites:
- A Next.js app using the App Router, with next-sanity v13 or later configured as described in Add live content to your application.
- Draft Mode set up in your app. See Implementing draft mode.
- A token with viewer access rights or lower, stored in the
SANITY_API_READ_TOKENenvironment variable.
Add the token to the client
Set token on the client that defineLive uses. The route handler in this guide uses the same client to open the drafts stream, and on a private dataset, published queries need the token too. SANITY_API_READ_TOKEN has no NEXT_PUBLIC_ prefix, so Next.js keeps it out of browser bundles:
import { createClient } from "next-sanity"
import { dataset, projectId } from "@/sanity/env"
export const client = createClient({
projectId,
dataset,
apiVersion: "2026-03-01",
useCdn: true,
// Private datasets need a token for every query, including published content
token: process.env.SANITY_API_READ_TOKEN,
})Keep the token out of the browser
Pass the token as serverToken so server components can fetch drafts in Draft Mode, and set browserToken: false so SanityLive never receives it:
import { defineLive } from "next-sanity/live";
import { client } from "@/sanity/lib/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,
// Keep the token out of the browser
browserToken: false,
})Create the relay route
Create a route handler that streams server-sent events. It listens to the Live Content API with includeDrafts: true, authorized by the token on the server-side client, and writes a change message whenever content changes:
import { draftMode } from "next/headers"
import { client } from "@/sanity/lib/client"
export async function GET(request: Request) {
const { isEnabled } = await draftMode()
if (!isEnabled) {
return new Response("Draft Mode is not enabled", { status: 401 })
}
const encoder = new TextEncoder()
let closed = false
const stream = new ReadableStream({
start(controller) {
const close = () => {
if (closed) return
closed = true
subscription.unsubscribe()
controller.close()
}
// The token on the server-side client authorizes the drafts stream
const subscription = client.live.events({ includeDrafts: true }).subscribe({
next: (event) => {
// Forward a bare signal. Sync tags and event IDs stay on the server.
if (
event.type === "message" ||
event.type === "restart" ||
event.type === "reconnect"
) {
controller.enqueue(encoder.encode("data: change\n\n"))
}
},
error: (error) => {
console.error(error)
close()
},
})
// Stop listening when the browser disconnects
request.signal.addEventListener("abort", close)
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
})
}In this example:
- The route returns
401unless Draft Mode is enabled, so only visitors previewing drafts can open the stream. - It forwards a
changemessage formessageevents, and forrestartandreconnectevents, which mean earlier events may have been missed. - It sends no sync tags or event IDs. The browser only learns that something changed.
- It unsubscribes from the Live Content API when the browser disconnects.
Each open browser tab holds one connection to the route, and the route holds one connection to the Live Content API for that tab. Hosting platforms also cap how long a response can stay open. When the host ends the stream, EventSource in the browser reconnects on its own, so check your platform's maximum function duration to know how often that happens.
Refresh the page when content changes
Create a client component that connects to the route with EventSource and calls router.refresh() on every message. SanityLive does the same when it receives draft events with a browserToken:
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export function LiveDraftRefresh() {
const router = useRouter()
useEffect(() => {
const source = new EventSource("/api/live")
// Rerun server components, which refetch with the server-side token
source.onmessage = () => router.refresh()
return () => source.close()
}, [router])
return null
}Render the relay in Draft Mode
In your root layout, render LiveDraftRefresh when Draft Mode is enabled and SanityLive otherwise. Visitors outside Draft Mode keep receiving published updates through SanityLive, and editors previewing drafts get updates through the relay:
import { draftMode } from "next/headers"
import { LiveDraftRefresh } from "@/components/LiveDraftRefresh"
import { SanityLive } from "@/sanity/lib/live"
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const { isEnabled: isDraftMode } = await draftMode()
return (
<html lang="en">
<body>
{children}
{isDraftMode ? <LiveDraftRefresh /> : <SanityLive />}
</body>
</html>
)
}This also covers previews in the Presentation tool. The VisualEditing component doesn't refresh the page when content changes, so the relay supplies those updates without causing a second refresh.
Refreshes aren't filtered by query
The route doesn't know which queries the current page uses, so every change in the dataset triggers a refresh. On a dataset with frequent edits, debounce router.refresh() in LiveDraftRefresh to limit how often the page rerenders.
Troubleshooting
The route returns 401
The route responds with Draft Mode is not enabled when the request has no Draft Mode cookie. LiveDraftRefresh only renders in Draft Mode, and EventSource sends cookies on same-origin requests, so this usually means the route was requested directly or from another origin.
The stream closes right after it opens
If the client has no token, client.live.events() fails with The live events API requires a token or withCredentials when 'includeDrafts: true'. Please update your client configuration. The token should have the lowest possible access role. If the Live Content API rejects the token, the error is EventSource connection failed, with the HTTP status on the error's status property. The route logs the error and closes the stream, and EventSource in the browser reconnects and fails the same way. Check that SANITY_API_READ_TOKEN is set in the server environment and belongs to the project.
Next steps
- To handle live events directly with the JavaScript client, see Listening to content updates with @sanity/client.
- To learn how sync tags and live events fit together, see the Live Content API overview.