> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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.

> [!NOTE]
> 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](https://www.sanity.io/docs/developer-guides/live-content-guide).

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](https://www.sanity.io/docs/developer-guides/live-content-guide).
- Draft Mode set up in your app. See [Implementing draft mode](https://www.sanity.io/docs/visual-editing/implementing-draft-mode).
- A token with [viewer access rights](https://www.sanity.io/docs/user-guides/roles) or lower, stored in the `SANITY_API_READ_TOKEN` environment 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:

**src/sanity/lib/client.ts**

```typescript
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:

**src/sanity/lib/live.ts**

```typescript
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:

**src/app/api/live/route.ts**

```typescript
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 `401` unless Draft Mode is enabled, so only visitors previewing drafts can open the stream.
- It forwards a `change` message for `message` events, and for `restart` and `reconnect` events, 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`:

**src/components/LiveDraftRefresh.tsx**

```tsx
"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:

**src/app/layout.tsx**

```tsx
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.

> [!TIP]
> 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](https://www.sanity.io/docs/apis-and-sdks/js-client-realtime).
- To learn how sync tags and live events fit together, see the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) overview.

