Visual Editing with Astro
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/astrointegration, which provides a pre-configured Sanity client with Content Source Map encoding. - A custom
loadQueryfunction 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.
Prerequisites
Section titled âPrerequisitesâ- 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/astrov3.5.0 or later,@astrojs/reactv6+, and@astrojs/nodev11+.- 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:4321added 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
npm install @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groqpnpm
pnpm add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groqyarn
yarn add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groqbun
bun add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groqIn 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
npm create sanity@latest -- --dataset production --template clean --typescript --output-path studiocd studiopnpm
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studiocd studioyarn
yarn create sanity@latest --dataset production --template clean --typescript --output-path studiocd studiobun
bun create sanity@latest --dataset production --template clean --typescript --output-path studiocd studioHow the pieces fit together
Section titled âHow the pieces fit togetherâBefore diving into the code, hereâs what happens at runtime when an editor opens the Presentation Tool:
- The Studio loads the Astro frontend inside an iframe. The URL it loads comes from the
initialfield in the Presentation Tool configuration. - 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. - With draft mode active,
loadQueryreturns 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. - 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. - When an editor clicks an overlay, the Studio navigates to that document and field.
- When an editor changes a field, the
<SanityVisualEditing />componentâsrefreshcallback 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.enablepath (/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 infrontend/src/pages/.- The
stega.studioUrlin the@sanity/astrointegration config must point to the running Studio.- The Sanity project must have the frontendâs origin in its CORS settings with Allow credentials enabled.
Environment variables
Section titled âEnvironment variablesâSet up environment variables for your Astro app (frontend) and Studio (studio).
frontend/.env
PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_IDPUBLIC_SANITY_DATASET=YOUR_DATASETSANITY_API_READ_TOKEN=YOUR_VIEWER_TOKENstudio/.env
SANITY_STUDIO_PROJECT_ID=YOUR_PROJECT_IDSANITY_STUDIO_DATASET=YOUR_DATASETSANITY_STUDIO_PREVIEW_URL=http://localhost:4321PUBLIC_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.
Studio setup
Section titled âStudio setupâ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.
Presentation Tool configuration
Section titled âPresentation Tool configurationâ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 tohttp://localhost:4321/api/draft-mode/enablewith authentication parameters. This is what activates draft mode so the frontend returns draft content with stega encoding.
Document locations
Section titled âDocument locationsâ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
npx sanity cors add http://localhost:4321 --credentialspnpm
pnpm dlx sanity cors add http://localhost:4321 --credentialsyarn
yarn dlx sanity cors add http://localhost:4321 --credentialsbun
bunx sanity cors add http://localhost:4321 --credentialsFor production, youâd add your deployed frontend URL as well.
Astro setup
Section titled âAstro setupâThese files live in frontend/. The structure follows a standard Astro project with server-side rendering enabled.
Astro configuration
Section titled âAstro configurationâ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/astrointegration configures a Sanity client thatâs available throughout your app via thesanity:clientvirtual module. No manualcreateClientcall 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/astro3.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.
The Sanity client
Section titled âThe Sanity clientâ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.
Draft mode helper
Section titled âDraft mode helperâ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.
Fetching data
Section titled âFetching dataâ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 withwithKeyArraySelector, 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.
GROQ queries
Section titled âGROQ queriesâ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 layout
Section titled âThe layoutâ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.
The SanityVisualEditing component
Section titled âThe SanityVisualEditing componentâ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