Skip to content

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

Configure Sanity’s Presentation Tool, draft mode, and visual editing overlays to work with an Astro 7 server-rendered frontend.

This guide walks through the specific wiring that makes Sanity’s visual editing work with an Astro application. It allows for automatic content refresh on edit, perspective switching, and more flexibility at the expense of more complexity.

[!TIP] If you’re looking for a more drop-in, but less featured visual editing implementation, check out the Building a blog with Sanity and Astro guide.

By the end, editors will be able to open the Presentation Tool in the Studio, see the frontend in a live preview, click on any text element to jump to the corresponding field, and see changes reflected after each edit.

What you’ll set up:

  • The @sanity/astro integration, which provides a pre-configured Sanity client with Content Source Map encoding.
  • A custom loadQuery function that switches between published and draft content based on cookies.
  • Cookie-based draft mode routes to toggle between published and draft content.
  • The Presentation Tool with document-to-URL mapping.
  • A custom <SanityVisualEditing /> React component that powers click-to-edit overlays, browser history sync, and content refresh.

The guide assumes you already have document types defined in your Studio and pages that render them. The focus is purely on the integration layer: the files and configuration that connect the two apps.

[!NOTE] Astro and the Live Content API Next.js integrations use the Live Content API (defineLive / <SanityLive />) for real-time re-rendering without page reloads. Astro does not have an equivalent. Instead, when an editor changes a field, the <SanityVisualEditing /> component triggers a full page reload to fetch fresh content from the server. This is the standard approach for Astro and works well in practice.

  • Node.js 20+.
  • Astro 7 with output: "server". Visual editing requires server-side rendering because draft mode depends on per-request cookie checking. Static output mode will not work.
  • @sanity/astro v3.5.0 or later, @astrojs/react v6+, and @astrojs/node v11+.
  • A Sanity project with a dataset. Create one if you don’t have one.
  • An API token with Viewer permissions for that project. Create one under API → Tokens in your project settings.
  • http://localhost:4321 added as a CORS origin with Allow credentials checked.

You can create a basic Astro app by following the Astro quickstart. Then, navigate to the Astro project’s frontend and make sure you have the latest packages by running the following command:

npm

Terminal window
npm install @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq

pnpm

Terminal window
pnpm add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq

yarn

Terminal window
yarn add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq

bun

Terminal window
bun add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq

In this example, we’re separating the Studio from the Astro app. You can create a new Studio by running the following command in your project root:

npm

Terminal window
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studio
cd studio

pnpm

Terminal window
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio

yarn

Terminal window
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio

bun

Terminal window
bun create sanity@latest --dataset production --template clean --typescript --output-path studio
cd studio

Before diving into the code, here’s what happens at runtime when an editor opens the Presentation Tool:

  1. The Studio loads the Astro frontend inside an iframe. The URL it loads comes from the initial field in the Presentation Tool configuration.
  2. The Studio hits the draft mode enable route on the frontend (/api/draft-mode/enable). This sets a cookie that activates draft mode in the iframe session.
  3. With draft mode active, loadQuery returns strings with invisible characters embedded in them. These characters are Content Source Maps (called “stega”) that encode which document and field each string came from, along with the Studio URL.
  4. The <SanityVisualEditing /> component (which only renders during draft mode) reads those encoded strings from the DOM and draws click-to-edit overlays on every text element.
  5. When an editor clicks an overlay, the Studio navigates to that document and field.
  6. When an editor changes a field, the <SanityVisualEditing /> component’s refresh callback triggers a full page reload. The page re-fetches from the server with the updated draft content.

[!NOTE] Contracts between the two apps. If you change one side, check the other.

  • The Studio’s previewMode.enable path (/api/draft-mode/enable) must match an actual API route in the Astro app.
  • The URLs returned by resolve.ts (e.g., /post/${slug}) must match actual page routes in frontend/src/pages/.
  • The stega.studioUrl in the @sanity/astro integration config must point to the running Studio.
  • The Sanity project must have the frontend’s origin in its CORS settings with Allow credentials enabled.

Set up environment variables for your Astro app (frontend) and Studio (studio).

frontend/.env

Terminal window
PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID
PUBLIC_SANITY_DATASET=YOUR_DATASET
SANITY_API_READ_TOKEN=YOUR_VIEWER_TOKEN

studio/.env

SANITY_STUDIO_PROJECT_ID=YOUR_PROJECT_ID
SANITY_STUDIO_DATASET=YOUR_DATASET
SANITY_STUDIO_PREVIEW_URL=http://localhost:4321

PUBLIC_SANITY_PROJECT_ID and PUBLIC_SANITY_DATASET are public because the @sanity/astro integration needs them in astro.config.mjs (loaded via Vite’s loadEnv).

SANITY_API_READ_TOKEN is server-only and never exposed to the client bundle. It’s passed to loadQuery only when draft mode is active, to authenticate requests for draft content.

Note that Studio environment variables should always start with SANITY_STUDIO. However, it’s safe to hard-code the projectId, dataset, and preview URL in sanity.config.ts if you prefer.

These files live in studio/. If you’re setting up a new Studio from scratch, these examples use a blog schema with post, author, and category document types.

The Presentation Tool is a Studio plugin that renders your frontend inside an iframe and enables the visual editing workflow. Configure it in sanity.config.ts:

studio/sanity.config.ts

import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { presentationTool } from "sanity/presentation";
import { schema } from "./schemaTypes";
import { resolve } from "./lib/resolve";
export default defineConfig({
projectId: process.env.SANITY_STUDIO_PROJECT_ID || 'YOUR_PROJECT_ID',
dataset: process.env.SANITY_STUDIO_DATASET || 'production',
plugins: [
structureTool(),
presentationTool({
resolve,
previewUrl: {
initial:
process.env.SANITY_STUDIO_PREVIEW_URL || "http://localhost:4321",
previewMode: {
enable: "/api/draft-mode/enable",
},
},
}),
],
schema,
});

The important fields here:

  • resolve: This defines the document location resolver. You’ll set this up in the next section.
  • previewUrl.initial: The full URL of the Astro app. The Presentation Tool loads this in the iframe. When the Studio and frontend are separate apps (as they are here), this is required.
  • previewUrl.previewMode.enable: The path (relative to initial) that the Studio calls to activate draft mode. The Studio makes a GET request to http://localhost:4321/api/draft-mode/enable with authentication parameters. This is what activates draft mode so the frontend returns draft content with stega encoding.

Document locations tell the Presentation Tool which frontend URLs correspond to which document types. This powers two things: when you select a document in the Studio, the iframe navigates to the right page; and documents show location badges linking to their frontend URLs.

studio/lib/resolve.ts

import { defineLocations } from "sanity/presentation";
import type { PresentationPluginOptions } from "sanity/presentation";
export const resolve: PresentationPluginOptions["resolve"] = {
locations: {
// The key is the document type name from your schema
post: defineLocations({
select: {
title: "title",
slug: "slug.current",
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || "Untitled",
href: `/post/${doc?.slug}`,
},
{
title: "Home",
href: "/",
},
],
}),
}),
},
};

select uses GROQ-like field paths to pull data from the document. resolve receives that data and returns an array of {title, href} objects. The first location is treated as the primary one. You can add multiple locations if a document appears on several pages (for example, a post appears on its own page and on the posts index).

The Sanity project needs http://localhost:4321 added as a CORS origin with Allow credentials enabled. If you already added this in the prerequisites, you’re set. If not, add it in your project settings at sanity.io/manage under API → CORS Origins, or add it with the CLI:

npm

Terminal window
npx sanity cors add http://localhost:4321 --credentials

pnpm

Terminal window
pnpm dlx sanity cors add http://localhost:4321 --credentials

yarn

Terminal window
yarn dlx sanity cors add http://localhost:4321 --credentials

bun

Terminal window
bunx sanity cors add http://localhost:4321 --credentials

For production, you’d add your deployed frontend URL as well.

These files live in frontend/. The structure follows a standard Astro project with server-side rendering enabled.

frontend/astro.config.mjs

import { defineConfig } from "astro/config";
import sanity from "@sanity/astro";
import react from "@astrojs/react";
import node from "@astrojs/node";
import { loadEnv } from "vite";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
process.env.NODE_ENV,
process.cwd(),
"",
);
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
sanity({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
useCdn: false,
apiVersion: "2026-03-01",
stega: {
studioUrl: "http://localhost:3333",
},
}),
react(),
],
vite: {
optimizeDeps: {
include: [
"react/compiler-runtime",
"lodash/isObject.js",
"lodash/groupBy.js",
"lodash/keyBy.js",
"lodash/partition.js",
"lodash/sortedIndex.js",
],
},
},
});

There’s a lot here, so let’s break it down:

  • output: “server”: Enables server-side rendering. This is required because draft mode depends on reading cookies from each incoming request to decide whether to return published or draft content. Static builds can’t do this.
  • adapter: node({ mode: “standalone” }): The Node.js adapter runs the Astro app as a standalone server. You could also use other adapters (Vercel, Cloudflare, etc.) for deployment.
  • sanity({ 
 }): The @sanity/astro integration configures a Sanity client that’s available throughout your app via the sanity:client virtual module. No manual createClient call needed.
  • stega.studioUrl: When draft mode is active and stega encoding is enabled, this URL is embedded in the invisible characters so the overlay knows where to send the editor when they click. For production, point this to your deployed Studio URL.
  • useCdn: false: Disabled because we need fresh data for draft content. In a production setup, you might conditionally enable it for published content.
  • react(): Required because the visual editing overlay components (SanityVisualEditing, DisableDraftMode) are React components that run in the browser.
  • vite.optimizeDeps.include: Pre-bundles certain dependencies that Vite’s dev server would otherwise fail to optimize on the fly. Without these entries, you may see module resolution errors in development. @sanity/astro 3.5.0 and later pre-bundles a related set of modules automatically for its embedded Studio setup, but that set doesn’t cover the modules listed here, so keep these entries.

Unlike Next.js where you create the client manually with createClient, the @sanity/astro integration provides a pre-configured client via a virtual module. To use it, add the type references in your env file:

frontend/src/env.d.ts

/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />

The second line tells TypeScript about the sanity:client virtual module, which you can then import anywhere:

import { sanityClient } from "sanity:client";

The client is automatically configured with the projectId, dataset, apiVersion, and stega settings from astro.config.mjs.

Astro doesn’t have a dedicated draftMode like Next.js, so we implement draft mode with cookies. This small helper reads the draft mode state from Astro.cookies:

frontend/src/sanity/lib/draft-mode.ts

import type { AstroCookies } from "astro";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
export function getDraftModeProps(cookies: AstroCookies) {
return {
perspectiveCookie: cookies.get(perspectiveCookieName)?.value ?? undefined,
};
}

This reads a client-writable cookie that is set by the draft mode enable route and kept up to date by the <SanityVisualEditing /> component. It stores the editor’s current perspective preference (e.g., a specific content release). The Presentation Tool then interacts with this when the editor switches perspectives in the Studio.

This is the central piece that replaces Next.js’s defineLive / sanityFetch. It’s a custom loadQuery function that handles perspective switching, stega encoding, and source maps based on whether draft mode is active:

frontend/src/sanity/lib/load-query.ts

import type { ClientPerspective, QueryParams } from "@sanity/client";
import { sanityClient } from "sanity:client";
const token = import.meta.env.SANITY_API_READ_TOKEN;
function parsePerspective(
raw: string | undefined,
): ClientPerspective | undefined {
if (!raw) return undefined;
const decoded = decodeURIComponent(raw);
if (decoded.startsWith("[")) {
try {
return JSON.parse(decoded) as ClientPerspective;
} catch {
return undefined;
}
}
return decoded as ClientPerspective;
}
export async function loadQuery<QueryResponse>({
query,
params,
perspectiveCookie = undefined,
}: {
query: string;
params?: QueryParams;
perspectiveCookie?: string | undefined;
}) {
const draftMode = perspectiveCookie ? true : false;
if (draftMode && !token) {
throw new Error(
"The `SANITY_API_READ_TOKEN` environment variable is required during Visual Editing.",
);
}
const perspective: ClientPerspective = draftMode
? (parsePerspective(perspectiveCookie) ?? "drafts")
: "published";
const { result, resultSourceMap } = await sanityClient.fetch<QueryResponse>(
query,
params ?? {},
{
filterResponse: false,
perspective,
resultSourceMap: draftMode ? "withKeyArraySelector" : false,
stega: draftMode,
...(draftMode ? { token } : {}),
},
);
return {
data: result,
sourceMap: resultSourceMap,
perspective,
};
}

The function handles two modes:

  • Published mode (default): Uses the "published" perspective, no stega encoding, no source maps, no token. This is what visitors see.
  • Draft mode: Uses the "drafts" perspective (or a custom perspective from the cookie for Content Releases), enables stega encoding and source maps with withKeyArraySelector, and authenticates with the API token.

The parsePerspective helper deserializes the perspective cookie, which can be either a simple string like "drafts" or a JSON-encoded array for Content Release stacks. This exact implementation isn’t required, but works with the rest of the code.

The filterResponse: false option tells the client to return both the query result and the source map, rather than just the result.

Queries are defined using defineQuery from the groq package, which enables TypeGen to generate result types. If you don’t have it already, add groq to your project dependencies:

frontend/src/sanity/lib/queries.ts

import { defineQuery } from "groq";
export const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt
}`,
);
export const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]{
_id,
_type,
title,
"slug": slug.current,
publishedAt,
mainImage {
asset->{ _id, url, metadata { lqip, dimensions } },
alt,
hotspot,
crop
},
body[]{
...,
_type == "image" => {
...,
asset->{ _id, url, metadata { lqip, dimensions } },
alt
}
},
author->{ _id, name, "slug": slug.current },
categories[]->{ _id, title }
}`,
);

With TypeGen configured in the Studio’s sanity.cli.ts, running sanity typegen generate produces typed result types (POSTS_QUERY_RESULT, POST_QUERY_RESULT) in frontend/sanity.types.ts. These are used as generics with loadQuery<POST_QUERY_RESULT>() for type-safe data access.

The shared layout conditionally renders visual editing components when draft mode is active:

frontend/src/layouts/Layout.astro

---
import SanityVisualEditing from "../components/SanityVisualEditing";
import DisableDraftMode from "../components/DisableDraftMode";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
const draftMode = Astro.cookies.has(perspectiveCookieName);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
</head>
<body>
<slot />
{draftMode && <>
<SanityVisualEditing client:only="react" />
<DisableDraftMode client:only="react" />
</>}
</body>
</html>

Two components are doing the visual editing work here:

  • : scans the DOM for stega-encoded strings, decodes the Content Source Map data embedded in them (document ID, field path, Studio URL), and draws transparent overlays on top of each element. It also handles browser history synchronization with the Studio and triggers page reloads when content changes.
  • : renders a floating button to exit draft mode, but only when the user is viewing the frontend directly (not inside the Presentation Tool’s iframe).

The client:only="react" directive is critical. It tells Astro to render these components exclusively on the client side using React, with no server-side rendering attempt. This is necessary because both components use browser-only APIs (window, document.cookie, postMessage).

The Astro.cookies.has(perspectiveCookieName) check is the gate. Outside of draft mode, the page renders clean published content with no overlays and no invisible characters.

This is the most complex Astro-specific piece. In Next.js, next-sanity provides a <VisualEditing /> component that handles everything. In Astro, we need a custom component because Astro doesn’t have a client-side router, and the built-in @sanity/astro visual editing component doesn’t expose perspective change handling.

The component has three responsibilities: browser history synchronization, perspective cookie management, and content refresh.

History synchronization: The Presentation Tool needs to keep its URL bar in sync with the iframe. In a Next.js or React SPA, the router provides navigation events. Astro uses full page loads, so we monkey-patch pushState and replaceState to detect navigation, and listen for popstate and hashchange events.

When the Studio navigates (e.g., the editor selects a different document, and resolve.ts maps it to a new URL), the update callback calls window.location.assign() to trigger a full navigation. This is the key difference from SPA frameworks, where navigation would happen client-side without a page reload.

Perspective cookie management: When an editor switches perspectives in the Studio (e.g., viewing a Content Release), the component writes the new perspective to a cookie so the server can use it in loadQuery.

The <VisualEditing /> component from @sanity/visual-editing/react does the heavy lifting of reading stega-encoded strings and drawing overlays. Our wrapper provides the Astro-specific adapters:

  • history: Tells the Studio what URL the iframe is showing, and handles navigation requests from the Studio.
  • portal={true}: Renders the overlay outside the normal DOM tree so it doesn’t interfere with page layout.
  • onPerspectiveChange: Writes the new perspective to a cookie and reloads the page so the server can fetch content with the new perspective.
  • refresh: Called when the Studio detects a content change. Triggers a full page reload to get fresh server-rendered content.
  • keepStegaOnCopy: Optional boolean prop, default false. When omitted, intercepts copy events and strips stega encoding from both text/plain and text/html clipboard payloads, so users copying text from the preview page don’t get invisible characters in their clipboard. Pass keepStegaOnCopy to disable this behavior and preserve stega in clipboard content.
  • onSuspiciousStega: Optional callback prop (opt-in). Reports stega found in unsafe DOM placements: element attributes (class, id, href, src, style, data-*, etc.), inside (title, meta[content], JSON-LD), in