Cheetah
Quick edit for Sanity, on your own site. An editor starts a session, clicks
any text, image or rich text field on the page, and edits it right there.
They choose where changes go as they start: each document's draft, or a
content release created for the session, which they publish, discard, or hand
over to the Studio when they're done.
Cheetah runs on your website, not in the Studio, and doesn't need the
Presentation tool. Nothing is installed in the Studio: it works through
content releases, which already exist there.

Published as @sanity-labs/cheetah.
Cheetah is built to work with any framework. Everything on the server (the
endpoint, sessions, and the Content Lake calls) speaks the Fetch API and has no
framework dependency, and the page only needs stega-encoded content to know what
is editable. Today the toolbar itself is a React component, so Next.js and
Astro have ready-made entry points, other frameworks can use the generic
handler, and a framework-agnostic toolbar is on the roadmap.
How it works
- The page is the map. Your pages render with
stega during a session, so
every string carries an invisible pointer to the document and field it came
from. The toolbar reads those pointers to know what each click edits.
- The editor's own token does the writing. A session starts from a Sanity
token belonging to the editor, which the server keeps in an encrypted,
httpOnly cookie. Every read and write in the session uses it, so the Content
Lake enforces what that person may change. Your site's tokens are never used
for writes.
- The editor picks drafts or a release. The toolbar asks before a
session starts. Drafts saves to each document's draft, and Publish
puts the drafts the session edited live in one click. New release
creates a release for the session, with a change count, "Discard all",
"Accept all" and a link to the release in the Studio. Nothing is published
until the editor says so, and no release is created unless they ask for one.
- No releases, no problem. On a plan without content releases, pass
releases: false to the route (or handler) and releases={false} to
<QuickEdit>: the toolbar then only offers drafts. Left on, asking for a
release on such a plan is refused with a message to start in drafts.
Three pieces make that work, and every framework wires the same three. Only the
last is tied to React today:
- An endpoint,
POST /api/quick-edit, that starts, saves and ends
sessions. It must be served from the same origin as your pages, because the
session lives in a cookie on that origin.
- Session-aware rendering. While a session runs, your pages fetch content
from the session's perspective with stega on.
- The toolbar, which the editor sees on the page. It is a React
component for now (
<QuickEdit>), and reaches the endpoint through a small
adapter, so a non-React toolbar can reuse the same server.
Requirements
- A Sanity project. Release sessions need a plan with content releases;
drafts sessions work on any plan.
- Server rendering for the quick-edit endpoint and for the pages an editor
edits. Statically generated pages can't show a session's changes.
@sanity/client 8.9 or later.
- React 19 on the page that hosts the toolbar. Only the toolbar needs it, not
the server code, so a site on another framework can still run the endpoint
and mount the toolbar as a React island.
- For the framework entry points: Next.js 15 or later (App Router), or
Astro 6 or later with
@astrojs/react
and an adapter.
Install
npm install @sanity-labs/cheetah @sanity/client react
The package has four entry points:
@sanity-labs/cheetah | The <QuickEdit> toolbar, createQuickEditAdapter, editors |
@sanity-labs/cheetah/next | createQuickEditRoute for the Next.js App Router |
@sanity-labs/cheetah/astro | createQuickEditRoute for Astro |
@sanity-labs/cheetah/server | createQuickEditHandler, a Fetch API handler for any framework |
@sanity-labs/cheetah/portable-text | createPortableTextEditor, to give rich text your own schema |
Generate a secret of at least 32 random characters and keep it server-side,
as QUICK_EDIT_SECRET in the examples below. Both it and the read token are
environment variables you set on your host (see
Environment variables):
openssl rand -base64 32
You will also want, as you set up rendering below, a read token for your
project that can see drafts and releases (SANITY_VIEWER_TOKEN in the
examples; a Viewer token is enough).
Finally, add your site to the project's CORS origins so editors can sign in
from it (see Starting a session):
sanity cors add https://example.com --credentials
Next.js (App Router)
1. Mount the endpoint
import { createQuickEditRoute } from "@sanity-labs/cheetah/next"
export const quickEdit = createQuickEditRoute(() => ({
dataset: "production",
projectId: "abc123",
secret: process.env.QUICK_EDIT_SECRET!,
studioUrl: "https://example.sanity.studio",
}))
import { quickEdit } from "@/lib/quick-edit"
export const POST = quickEdit.POST
Options may be an object or a function; a function is called on the first
request, so secrets are read at runtime rather than at build time.
While a session runs, the route turns on Next's draft mode and sets the
sanity-preview-perspective cookie to the session's perspective (its release
stacked over drafts, or just drafts without a release), which is what
next-sanity reads. Ending the session turns both off. Pass
draftMode: false to manage preview yourself, reading the session with
getQuickEditSession() instead.
2. Mount the toolbar
"use client"
import { createQuickEditAdapter, QuickEdit } from "@sanity-labs/cheetah"
import { useEffect, useState } from "react"
const adapter = createQuickEditAdapter()
export function QuickEditToolbar({ inSession }: { inSession: boolean }) {
const [asked, setAsked] = useState(false)
useEffect(() => setAsked(location.search.includes("quick-edit")), [])
if (!inSession && !asked) return null
return <QuickEdit adapter={adapter} sessionRendered={inSession} />
}
import { draftMode } from "next/headers"
import { Suspense } from "react"
import { QuickEditToolbar } from "@/components/quick-edit-toolbar"
import { quickEdit } from "@/lib/quick-edit"
async function Toolbar() {
const { isEnabled } = await draftMode()
const session = isEnabled ? await quickEdit.getQuickEditSession() : null
return <QuickEditToolbar inSession={Boolean(session)} />
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Suspense>
<Toolbar />
</Suspense>
</body>
</html>
)
}
3. Fetch for the session
In any server component, route or generateMetadata:
import { draftMode } from "next/headers"
import { client } from "@/sanity/client"
import { quickEdit } from "@/lib/quick-edit"
const { isEnabled } = await draftMode()
const session = isEnabled ? await quickEdit.getQuickEditSession() : null
const content = await client.fetch(query, params, session
? {
perspective: session.perspective,
stega: { enabled: true, studioUrl: "https://example.sanity.studio" },
token: process.env.SANITY_VIEWER_TOKEN,
useCdn: false,
}
: {})
Already using next-sanity with defineLive or draft mode? The route's draft
mode and perspective cookie are what it reads, so its sanityFetch will
render the session's changes without this step.
Pages rendered in draft mode bypass Next's cache, so nothing from a session
reaches other visitors.
Next.js checklist
Astro
Astro needs on-demand rendering for the endpoint and for pages an editor
edits. Set output: "server", or keep a static site and mark those pages
export const prerender = false, with an
adapter installed.
The toolbar is a React component, so add
@astrojs/react:
npx astro add react
1. Mount the endpoint
First declare the two environment variables as server
secrets, so Astro reads them at runtime on any adapter rather than baking them
into the build:
import { defineConfig, envField } from "astro/config"
export default defineConfig({
env: {
schema: {
QUICK_EDIT_SECRET: envField.string({ access: "secret", context: "server" }),
SANITY_VIEWER_TOKEN: envField.string({ access: "secret", context: "server" }),
},
},
})
import { createQuickEditRoute } from "@sanity-labs/cheetah/astro"
import { getSecret } from "astro:env/server"
export const quickEdit = createQuickEditRoute(() => ({
dataset: "production",
projectId: "abc123",
secret: getSecret("QUICK_EDIT_SECRET")!,
studioUrl: "https://example.sanity.studio",
}))
import { quickEdit } from "@/lib/quick-edit"
export const prerender = false
export const POST = quickEdit.POST
Astro has no built-in preview mode, so the route doesn't switch one on. Read
the session in middleware instead, which is shown next. If your site does
have its own preview switch, pass
hooks: (context) => ({ onSession, onSessionEnd }) to the route; cookies set
on context.cookies reach the response.
The options function runs on the first request, so the secret is read at
runtime. It also receives the request's APIContext, if you need locals.
2. Read the session in middleware
import { defineMiddleware } from "astro:middleware"
import { quickEdit } from "@/lib/quick-edit"
export const onRequest = defineMiddleware(async (context, next) => {
if (!context.isPrerendered) {
context.locals.quickEdit = await quickEdit.getQuickEditSession(context)
}
return next()
})
declare namespace App {
interface Locals {
quickEdit?: null | import("@sanity-labs/cheetah/server").QuickEditRequestSession
}
}
3. Mount the toolbar
import { createQuickEditAdapter, QuickEdit } from "@sanity-labs/cheetah"
const adapter = createQuickEditAdapter()
export function QuickEditToolbar({ inSession }: { inSession: boolean }) {
return (
<QuickEdit
adapter={adapter}
// Astro hydrates islands after the toolbar mounts; this keeps their
// stega in place until they have, or hydration would fail.
pendingHydration="astro-island[ssr]"
sessionRendered={inSession}
/>
)
}
---
// src/layouts/Layout.astro
import { QuickEditToolbar } from "../components/QuickEditToolbar"
const session = Astro.locals.quickEdit
// An editor who isn't in a session yet asks for the toolbar with `?quick-edit`.
const offerToolbar = Boolean(session) || Astro.url.searchParams.has("quick-edit")
---
<html lang="en">
<body>
<slot />
{offerToolbar && <QuickEditToolbar client:load inSession={Boolean(session)} />}
</body>
</html>
Visitors who never edit get no toolbar and no React runtime from it.
4. Fetch for the session
---
// src/pages/index.astro
import { getSecret } from "astro:env/server"
import { client } from "../sanity/client"
const session = Astro.locals.quickEdit
const content = await client.fetch(query, params, session
? {
perspective: session.perspective, // [releaseId, "drafts"], or ["drafts"]
stega: { enabled: true, studioUrl: "https://example.sanity.studio" },
token: getSecret("SANITY_VIEWER_TOKEN"), // reads drafts and releases
useCdn: false,
}
: {})
---
A page that's prerendered can't see the session, so any page an editor should
edit needs export const prerender = false (or output: "server"). If you
cache responses at a CDN, vary on or bypass the cache for requests carrying the
sanity-labs-cheetah cookie, so a session's pages never reach other visitors.
Astro checklist
Any other framework
The handler takes a Fetch API Request and returns a Response, which
SvelteKit, React Router, Hono, Nuxt, Cloudflare Workers, Deno and Bun all speak:
import { createQuickEditHandler } from "@sanity-labs/cheetah/server"
const quickEdit = createQuickEditHandler({ dataset, projectId, secret, studioUrl })
export const POST = (request: Request) =>
quickEdit.handle(request, {
onSession({ perspective }) {
},
onSessionEnd() {
},
})
const session = await quickEdit.getSession(request)
Then render pages for the session, and mount the toolbar as in the Next.js and
Astro sections. <QuickEdit adapter={createQuickEditAdapter()}> works in any
React tree, including a React island inside a non-React framework. Without
React on the page, the server side above still works and the toolbar is what's
missing; see the roadmap.
Roadmap
What's supported, and what's planned. Planned items are intentions, not
commitments, and the order isn't fixed.
| Next.js (App Router) | @sanity-labs/cheetah/next | React |
| Astro | @sanity-labs/cheetah/astro | React island |
| Anything on the Fetch API | @sanity-labs/cheetah/server | React, where React is available |
| SvelteKit | Planned | Planned |
| Remix / React Router | Planned | React (works today) |
| Hydrogen | Planned, to be investigated | React (works today) |
A framework-agnostic toolbar. The biggest gap for non-React frameworks.
The plan is to move the toolbar to a custom element that any framework or plain
HTML page can mount, with <QuickEdit> becoming a thin React wrapper around
it. The adapter and the endpoint don't change, so nothing server-side needs
redoing. This also lets Astro and others skip the React runtime.
SvelteKit. The handler already fits a +server.ts endpoint, and a
hooks.server.ts can read the session with getSession(request) and pass it
to layouts. What's planned is a @sanity-labs/cheetah/sveltekit entry point
that wires those up, and a Svelte-friendly toolbar mount once the custom
element exists.
Remix and React Router. Remix is now React Router v7, so these are one
target. A resource route can already export action and call the generic
handle(request), and the toolbar is React, so the pieces work today. A
@sanity-labs/cheetah/react-router entry point would add the same
route-and-loader helpers the Next.js and Astro ones have, keeping the session
available to loaders.
Hydrogen. Hydrogen is a commerce layer on React Router v7 that runs on
Oxygen, and it has changed a lot recently, so how Cheetah fits is still to be
worked out. It could use the React Router entry point once that exists, but a
few things need checking first: Hydrogen's own session and context (where
Oxygen's environment variables live), its Content Security Policy and nonce
handling against the toolbar's inline styles, and the fact that most of a
storefront's content comes from Shopify, so only the Sanity-sourced parts of a
page can be edited.
Rendering pages for a session
During a session, fetch content the way the Presentation tool would: from the
session's release stacked over drafts, with stega on. Reads use your site's
own read token, as preview does today; only writes use the editor's.
Keep stega on for strings you only render. Anywhere your code compares or
parses a Sanity string (equality checks, switch, URLs, class names), clean it
first with stegaClean from @sanity/client/stega.
Pages rendered for a session must not be cached for other visitors.
Starting a session
The toolbar offers Sign in with Sanity, listing the ways to sign in:
Google, GitHub, email and password, and your organization's SSO if you've set
it up (below). Whichever the editor picks, the flow is the same:
- The toolbar asks the endpoint for the sign-in links. Each is a Sanity login
URL carrying your page's address, so Sanity knows where to send the editor
back.
- The editor signs in at the provider (for SSO, at your identity provider) and
Sanity returns them to the page with a one-time
sid in the address.
- The toolbar removes the
sid from the address and sends it to the
endpoint, which exchanges it with Sanity for the editor's token on the
server. A sid works once, and the token goes straight into the encrypted
session cookie, so it never reaches page scripts.
The editor still needs access to the project: a token must be able to edit
content to start a session. Signing in proves who they are; what they can
change is still decided by their roles in Sanity.
SSO
If your organization signs in with SAML single sign-on, pass its slug (the one
in sanity login --sso <slug>) as sso, and its provider is listed first,
above the project's own logins:
createQuickEditRoute(() => ({
sso: { organization: "acme", title: "Acme SSO" },
}))
The endpoint looks up the organization's enabled SAML providers, and each
becomes a button that takes the editor to your identity provider and back with
a sid, exactly as above. Nothing about SSO is stored on your site, and no
extra secrets are needed.
title is the button's label. Left out, it's the provider's name, or "SSO"
when the provider is just named after its type.
- An organization with more than one enabled provider gets one button for each,
labelled with the provider's name in brackets.
- Disabled providers are skipped, and an organization with none adds nothing.
- SSO links carry the same return address as the others, so the CORS origins
step below applies to them too.
Sanity only returns editors to origins on the project's CORS origins list,
so add your site there (sanity cors add https://example.com --credentials,
or manage.sanity.io → API → CORS origins, with "Allow credentials" on). The
toolbar says so when the site isn't on the list. Pass signIn={false} to
<QuickEdit> to leave the button out; pasting a Sanity token with editor
access stays available as "Use a token".
The adapter reloads the page when a session starts or ends. Pass refresh to
createQuickEditAdapter if your site needs a different address for the
session's render, such as a cache-busting parameter. The adapter leaves the
?quick-edit parameter out of the session's Studio links.
In local development you can skip the token: pass devToken with a token from
your .env to the route, and sessions start without asking. Never set devToken in a deployed environment, or anyone
who can reach the site can edit with it.
Editors
Text, code, image and Portable Text fields each have a default editor. Replace
any of them with the editors prop. Portable Text uses a basic schema by
default (paragraphs, headings, bold, italic, code, lists, links, images and
code blocks); give it yours with createPortableTextEditor from
@sanity-labs/cheetah/portable-text:
import { QuickEdit, defaultEditors } from "@sanity-labs/cheetah"
import {
basicPortableText,
createPortableTextEditor,
} from "@sanity-labs/cheetah/portable-text"
const portableText = createPortableTextEditor({
...basicPortableText({ dataset: "production", projectId: "abc123" }),
})
<QuickEdit adapter={adapter} editors={{ ...defaultEditors, portableText }} />
Options
Route and handler
Passed to createQuickEditRoute (Next.js, Astro) or createQuickEditHandler.
projectId | Your Sanity project ID. Required. |
dataset | Your dataset. Required. |
secret | 32+ random characters, kept server-side. Encrypts the session cookie. Required. |
studioUrl | Your Studio's URL, for the toolbar's Presentation and release links. Required. |
apiVersion | Content Lake API version. Defaults to a recent one. |
releases | false on plans without content releases, so only drafts are offered. |
devToken | Starts sessions without an editor's token. Local development only. |
sso | { organization, title? }: lists the organization's SAML sign-in first. See Starting a session. |
isAllowedOrigin | Which request origins may drive a session. See Behind a proxy. |
cookie | { name, maxAge, rememberFor, secure }. See Security. |
draftMode | Next.js only. false to manage preview yourself. |
hooks | Astro only. (context) => ({ onSession, onSessionEnd }). |
<QuickEdit>
adapter | From createQuickEditAdapter(). Required. |
sessionRendered | The server rendered this page for a running session, so stega comes off as soon as it mounts. |
releases | false to offer only drafts. |
signIn | false to leave out "Sign in with Sanity". |
tokenOptional | Never prompts for a token when the server refuses to start: the host supplies its own. |
autoStart | Starts a session as soon as the page loads, if none is running. |
editors | Replaces the default editor for any field kind. |
pendingHydration | Selector for markup awaiting hydration, such as Astro's astro-island[ssr]. |
createQuickEditAdapter
endpoint | Where the endpoint is mounted. Defaults to /api/quick-edit. |
path | The current page's path, for the Presentation link. |
refresh | Re-renders the page after a session starts or ends. Defaults to a reload. |
Behind a proxy
The endpoint refuses requests whose Origin header doesn't match the request's
own URL. If a proxy or rewrite puts a different host on the request than the
browser sees, say which origins may drive a session:
createQuickEditRoute(() => ({
isAllowedOrigin: (origin) => origin === "https://www.example.com",
}))
Security
- The session cookie is httpOnly,
SameSite=Lax, and sealed with AES-GCM
under a key derived from your secret, so neither page scripts nor anyone who
sees the cookie can lift the editor's token out of it. Rotating the secret
ends every session.
- The cookies are named
sanity-labs-cheetah (the session) and
sanity-labs-cheetah-login (who is signed in). Change them with
cookie.name.
- Sessions expire after twelve hours without activity (
cookie.maxAge). The
editor also stays signed in for 30 days (cookie.rememberFor, 0 to turn
off), so Exit or Publish followed by Quick edit doesn't ask for a token
again; the toolbar's Sign out forgets them.
- Requests must come from your own origin and are validated for shape and size
before they reach the Content Lake.
- Viewers are turned away: a token must be able to edit content to start a
session.
Environment variables
Cheetah needs two values at runtime, and both must stay on the server:
QUICK_EDIT_SECRET | 32+ random characters (openssl rand -base64 32). Encrypts the session cookie. | Yes |
SANITY_VIEWER_TOKEN | A Sanity token that can read drafts and releases, for rendering a session's pages. Never for writes. | Yes, if you render sessions yourself |
The names are yours to choose, since you pass the values to the route. A few
rules hold wherever you deploy:
- Server only. Never give either a browser-exposed prefix such as
NEXT_PUBLIC_ or PUBLIC_.
- Read at runtime, not build time. Pass options as a function, as in the
examples, so they're read on the first request. A value baked into a build
is hard to rotate and easy to leak.
- The same secret on every instance. Sessions are sealed with the secret,
so servers or regions with different values reject each other's cookies.
Changing it ends every session.
- Set it in each environment you edit from. Preview deployments need their
own copy if editors use them, and their addresses need adding to the
project's CORS origins.
devToken is for your machine only. Keep it in a local .env, never in
a deployed environment.
How to set them on common hosts:
| Local development | .env.local (Next.js) or .env (Astro), kept out of git | process.env (Next.js), astro:env/server (Astro) |
| Vercel | Project Settings → Environment Variables, or vercel env add; vercel env pull for local copies | process.env; astro:env/server with @astrojs/vercel |
| Netlify | Site configuration → Environment variables, or netlify env:set; mark them as secret | process.env; astro:env/server with @astrojs/netlify |
| Cloudflare Workers and Pages | wrangler secret put NAME, or the dashboard; .dev.vars for local development | astro:env/server with @astrojs/cloudflare; the env your handler receives |
| Fly.io, Railway, Render, Docker | fly secrets set, or the platform's dashboard or --env/-e flags | process.env; astro:env/server with @astrojs/node |
On platforms that hand secrets to each request instead of the process, such as
Cloudflare Workers, read them from the object you're given inside the options
function, and with the generic handler build it from that object on first use:
import { createQuickEditHandler, type QuickEditHandler } from "@sanity-labs/cheetah/server"
let quickEdit: QuickEditHandler | undefined
export default {
fetch(request: Request, env: { QUICK_EDIT_SECRET: string }) {
quickEdit ??= createQuickEditHandler({
dataset: "production",
projectId: "abc123",
secret: env.QUICK_EDIT_SECRET,
studioUrl: "https://example.sanity.studio",
})
return quickEdit.handle(request)
},
}
Astro validates declared secrets when it builds, even ones you don't import
there. If your build environment doesn't have them, give the build placeholder
values and set the real ones where the site runs.