New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@sanity-labs/cheetah

Package Overview
Dependencies
Maintainers
21
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sanity-labs/cheetah

Cheetah: quick edit for Sanity content in place on your own site, saved into a content release.

latest
Source
npmnpm
Version
0.1.0-beta.0
Version published
Weekly downloads
273
Maintainers
21
Weekly downloads
 
Created
Source

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.

The Cheetah toolbar on a page: a heading outlined in red, with its rich text editor open in a side panel and the toolbar along the bottom

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:

ImportWhat it is
@sanity-labs/cheetahThe <QuickEdit> toolbar, createQuickEditAdapter, editors
@sanity-labs/cheetah/nextcreateQuickEditRoute for the Next.js App Router
@sanity-labs/cheetah/astrocreateQuickEditRoute for Astro
@sanity-labs/cheetah/servercreateQuickEditHandler, a Fetch API handler for any framework
@sanity-labs/cheetah/portable-textcreatePortableTextEditor, 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

// lib/quick-edit.ts
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",
}))
// app/api/quick-edit/route.ts
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

// components/quick-edit-toolbar.tsx
"use client"

import { createQuickEditAdapter, QuickEdit } from "@sanity-labs/cheetah"
import { useEffect, useState } from "react"

const adapter = createQuickEditAdapter()

export function QuickEditToolbar({ inSession }: { inSession: boolean }) {
  // An editor who isn't in a session yet asks for the toolbar with `?quick-edit`.
  const [asked, setAsked] = useState(false)
  useEffect(() => setAsked(location.search.includes("quick-edit")), [])

  if (!inSession && !asked) return null
  return <QuickEdit adapter={adapter} sessionRendered={inSession} />
}
// app/layout.tsx
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() {
  // Only read the session cookie in draft mode, so ordinary visitors keep
  // statically rendered and cached pages.
  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, // [releaseId, "drafts"], or ["drafts"]
      stega: { enabled: true, studioUrl: "https://example.sanity.studio" },
      token: process.env.SANITY_VIEWER_TOKEN, // reads drafts and releases
      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

  • QUICK_EDIT_SECRET (32+ characters) and a read token set as environment variables
  • app/api/quick-edit/route.ts exports POST
  • <QuickEditToolbar> in the root layout
  • Content fetched with the session's perspective and stega on
  • Your site is on the project's CORS origins, with credentials allowed

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:

// astro.config.ts
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" }),
    },
  },
})
// src/lib/quick-edit.ts
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",
}))
// src/pages/api/quick-edit.ts
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

// src/middleware.ts
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()
})
// src/env.d.ts
/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    quickEdit?: null | import("@sanity-labs/cheetah/server").QuickEditRequestSession
  }
}

3. Mount the toolbar

// src/components/QuickEditToolbar.tsx
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

  • An adapter, output: "server" (or prerender = false on the pages)
  • @astrojs/react installed
  • QUICK_EDIT_SECRET (32+ characters) and a read token set as environment variables
  • Both variables declared in env.schema as server secrets
  • src/pages/api/quick-edit.ts exports prerender = false and POST
  • src/middleware.ts puts the session on locals, and App.Locals is typed
  • <QuickEditToolbar client:load> in the layout, with pendingHydration
  • Content fetched with the session's perspective and stega on
  • Your site is on the project's CORS origins, with credentials allowed

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 }) {
      // Turn on your preview mode, reading `perspective`.
    },
    onSessionEnd() {
      // Turn it off again.
    },
  })

// Elsewhere, to render for the session:
const session = await quickEdit.getSession(request)
// Or, where you only have cookies, `getSessionFromCookie(value)`, reading the
// cookie named `quickEdit.cookieName`.

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.

FrameworkEndpoint and sessionToolbar
Next.js (App Router)@sanity-labs/cheetah/nextReact
Astro@sanity-labs/cheetah/astroReact island
Anything on the Fetch API@sanity-labs/cheetah/serverReact, where React is available
SvelteKitPlannedPlanned
Remix / React RouterPlannedReact (works today)
HydrogenPlanned, to be investigatedReact (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.

OptionDescription
projectIdYour Sanity project ID. Required.
datasetYour dataset. Required.
secret32+ random characters, kept server-side. Encrypts the session cookie. Required.
studioUrlYour Studio's URL, for the toolbar's Presentation and release links. Required.
apiVersionContent Lake API version. Defaults to a recent one.
releasesfalse on plans without content releases, so only drafts are offered.
devTokenStarts sessions without an editor's token. Local development only.
sso{ organization, title? }: lists the organization's SAML sign-in first. See Starting a session.
isAllowedOriginWhich request origins may drive a session. See Behind a proxy.
cookie{ name, maxAge, rememberFor, secure }. See Security.
draftModeNext.js only. false to manage preview yourself.
hooksAstro only. (context) => ({ onSession, onSessionEnd }).

<QuickEdit>

PropDescription
adapterFrom createQuickEditAdapter(). Required.
sessionRenderedThe server rendered this page for a running session, so stega comes off as soon as it mounts.
releasesfalse to offer only drafts.
signInfalse to leave out "Sign in with Sanity".
tokenOptionalNever prompts for a token when the server refuses to start: the host supplies its own.
autoStartStarts a session as soon as the page loads, if none is running.
editorsReplaces the default editor for any field kind.
pendingHydrationSelector for markup awaiting hydration, such as Astro's astro-island[ssr].

createQuickEditAdapter

OptionDescription
endpointWhere the endpoint is mounted. Defaults to /api/quick-edit.
pathThe current page's path, for the Presentation link.
refreshRe-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:

VariableWhat it isRequired
QUICK_EDIT_SECRET32+ random characters (openssl rand -base64 32). Encrypts the session cookie.Yes
SANITY_VIEWER_TOKENA 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:

WhereSet them withRead them with
Local development.env.local (Next.js) or .env (Astro), kept out of gitprocess.env (Next.js), astro:env/server (Astro)
VercelProject Settings → Environment Variables, or vercel env add; vercel env pull for local copiesprocess.env; astro:env/server with @astrojs/vercel
NetlifySite configuration → Environment variables, or netlify env:set; mark them as secretprocess.env; astro:env/server with @astrojs/netlify
Cloudflare Workers and Pageswrangler secret put NAME, or the dashboard; .dev.vars for local developmentastro:env/server with @astrojs/cloudflare; the env your handler receives
Fly.io, Railway, Render, Dockerfly secrets set, or the platform's dashboard or --env/-e flagsprocess.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.

Keywords

sanity

FAQs

Package last updated on 21 Oct 2026

Related posts