Visual Editing with Astro
Configure Sanity’s Presentation Tool, draft mode, and visual editing overlays to work with an Astro 6 server-rendered frontend.
This guide walks through the specific wiring that makes Sanity's visual editing work with an Astro application. It allows for live-update, perspective switching, and more flexibility at the expense of more complexity.
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.
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
- Node.js 20+.
- Astro 5+ 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.3.1 or later,@astrojs/reactv5+, and@astrojs/nodev9+.- 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 with the following command:
npm install @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
pnpm add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
yarn add @sanity/astro @sanity/visual-editing @sanity/image-url @sanity/preview-url-secret astro-portabletext @portabletext/types groq
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 create sanity@latest -- --dataset production --template clean --typescript --output-path studio cd studio
pnpm create sanity@latest --dataset production --template clean --typescript --output-path studio cd studio
yarn create sanity@latest --dataset production --template clean --typescript --output-path studio cd studio
bun create sanity@latest --dataset production --template clean --typescript --output-path studio cd studio
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.
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
Set up environment variables for your Astro app (frontend) and Studio (studio).
PUBLIC_SANITY_PROJECT_ID=your-project-id PUBLIC_SANITY_DATASET=production SANITY_API_READ_TOKEN=your-viewer-token
SANITY_STUDIO_PROJECT_ID=<your-project-id> SANITY_STUDIO_DATASET=<your-dataset-name> 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.
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
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:
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 || '<your-dataset-name>',
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 flips the switch that makes the frontend return draft content with stega encoding.
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.
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).
CORS
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:
npx sanity cors add http://localhost:4321 --credentials
pnpm dlx sanity cors add http://localhost:4321 --credentials
yarn dlx sanity cors add http://localhost:4321 --credentials
bunx sanity cors add http://localhost:4321 --credentials
For production, you'd add your deployed frontend URL as well.
Astro setup
These files live in frontend/. The structure follows a standard Astro project with server-side rendering enabled.
Astro configuration
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. This is a known issue that
@sanity/astromay resolve upstream in a future release.
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:
/// <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
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:
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 sets a client-writable cookie managed 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
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:
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
Queries are defined using defineQuery from the groq integration, which enables TypeGen to generate result types. If you don’t have it already, add groq to your project dependencies:
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
The shared layout conditionally renders visual editing components when draft mode is active:
---
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:
- <SanityVisualEditing />: 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.
- <DisableDraftMode />: 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
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.
Here’s the full component. This isn’t the only way to approach this, but it allows the component to react to perspective changes passed to it by Studio’s Presentation tool.
import { useEffect, useMemo, useRef } from "react";
import {
VisualEditing,
type HistoryAdapter,
type HistoryUpdate,
} from "@sanity/visual-editing/react";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
import type { ClientPerspective } from "@sanity/client";
function serializePerspective(perspective: ClientPerspective): string {
return typeof perspective === "string"
? perspective
: JSON.stringify(perspective);
}
function getCookie(name: string): string | undefined {
const match = document.cookie.match(
new RegExp(`(?:^|; )${name}=([^;]*)`),
);
return match ? decodeURIComponent(match[1]) : undefined;
}
function setPerspectiveCookie(perspective: ClientPerspective): boolean {
const next = serializePerspective(perspective);
const current = getCookie(perspectiveCookieName);
if (current === next) return false;
document.cookie = `${perspectiveCookieName}=${encodeURIComponent(next)}; path=/; SameSite=None; Secure`;
return true;
}
function currentUrl() {
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function applyHistoryUpdate(
update: Pick<HistoryUpdate, "type" | "url">,
currentHref: string,
) {
switch (update.type) {
case "push":
if (currentHref !== update.url) window.location.assign(update.url);
return;
case "replace":
if (currentHref !== update.url) window.location.replace(update.url);
return;
case "pop":
window.history.back();
return;
}
}
export default function SanityVisualEditing() {
type Navigate = Parameters<HistoryAdapter["subscribe"]>[0];
const navigateRef = useRef<Navigate | undefined>(undefined);
const lastUrlRef = useRef("");
useEffect(() => {
const sync = () => {
const url = currentUrl();
if (url !== lastUrlRef.current) {
lastUrlRef.current = url;
navigateRef.current?.({ type: "push", title: document.title, url });
}
};
sync();
window.addEventListener("popstate", sync);
window.addEventListener("hashchange", sync);
const origPush = window.history.pushState;
const origReplace = window.history.replaceState;
window.history.pushState = function (...args) {
origPush.apply(window.history, args);
sync();
};
window.history.replaceState = function (...args) {
origReplace.apply(window.history, args);
sync();
};
return () => {
window.removeEventListener("popstate", sync);
window.removeEventListener("hashchange", sync);
window.history.pushState = origPush;
window.history.replaceState = origReplace;
};
}, []);
const history = useMemo<HistoryAdapter>(
() => ({
subscribe: (navigate) => {
navigateRef.current = navigate;
const url = currentUrl();
lastUrlRef.current = url;
navigate({ type: "push", title: document.title, url });
return () => {
if (navigateRef.current === navigate) {
navigateRef.current = undefined;
}
};
},
update: (update) => {
applyHistoryUpdate(update, window.location.href);
},
}),
[],
);
return (
<VisualEditing
history={history}
portal={true}
onPerspectiveChange={(perspective) => {
if (setPerspectiveCookie(perspective)) {
window.location.reload();
}
}}
refresh={() => {
return new Promise((resolve) => {
window.location.reload();
resolve();
});
}}
/>
);
}Draft mode routes
These two routes are the bridge between the Studio and the frontend.
Enable route:
import type { APIRoute } from "astro";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { perspectiveCookieName } from "@sanity/preview-url-secret/constants";
import { sanityClient } from "sanity:client";
export const GET: APIRoute = async ({ request, cookies, redirect }) => {
const token = import.meta.env.SANITY_API_READ_TOKEN;
if (!token) {
return new Response("Server misconfigured: missing read token", {
status: 500,
});
}
const clientWithToken = sanityClient.withConfig({ token });
const { isValid, redirectTo = "/", studioPreviewPerspective } = await validatePreviewUrl(
clientWithToken,
request.url,
);
if (!isValid) {
return new Response("Invalid secret", { status: 401 });
}
cookies.set(perspectiveCookieName, studioPreviewPerspective ?? "drafts", {
httpOnly: false,
sameSite: "none",
secure: true,
path: "/",
});
return redirect(redirectTo, 307);
};When an editor opens the Presentation Tool, the Studio makes a GET request to this route with authentication parameters. validatePreviewUrl (from @sanity/preview-url-secret) handles the handshake: it verifies the request came from a legitimate Studio session by checking a shared secret stored in the dataset. If valid, we set the cookie to the perspective value and redirect to the requested page.
The cookie settings are important:
- httpOnly:
trueallows client-side JavaScript to read and modifying it. Confirm this is what you want in your implementation. For this guide, it enables the refresh mechanism to function. - sameSite: "none": Required because the request comes from the Studio (a different origin) loading the frontend in an iframe.
- secure:
truerequired whensameSiteis"none".
In Next.js, defineEnableDraftMode from next-sanity/draft-mode wraps this logic. In Astro, we use validatePreviewUrl directly.
Disable route:
import type { APIRoute } from "astro";
import {perspectiveCookieName} from "@sanity/preview-url-secret/constants";
export const GET: APIRoute = async ({ cookies, redirect }) => {
cookies.delete(perspectiveCookieName, { path: "/" });
return redirect("/", 307);
};This clears the cookie and redirects to the homepage. It's called by the "Disable Draft Mode" button.
The "Disable Draft Mode" button
import { useIsPresentationTool } from "@sanity/visual-editing/react";
export default function DisableDraftMode() {
const isPresentationTool = useIsPresentationTool();
// null = still detecting, true = inside Presentation tool
if (isPresentationTool !== false) return null;
return (
<a
href="/api/draft-mode/disable"
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
zIndex: 50,
padding: "0.5rem 1rem",
borderRadius: "9999px",
backgroundColor: "#101112",
color: "#fff",
fontSize: "0.875rem",
textDecoration: "none",
}}
>
Disable Draft Mode
</a>
);
}This component renders a floating button to exit draft mode, but only when the user is viewing the frontend directly in a browser tab (not inside the Presentation Tool's iframe). Inside the Presentation Tool, the Studio controls draft mode, so the button would be redundant.
useIsPresentationTool returns true when the frontend is loaded inside a Presentation Tool iframe and false when it's loaded directly in a browser tab.
Fetching data in pages
With all the infrastructure in place, fetching data in page components is straightforward. The pattern is the same on every page: call loadQuery with the query and spread getDraftModeProps(Astro.cookies).
---
import type { POSTS_QUERY_RESULT } from "../../sanity.types";
import { POSTS_QUERY } from "../sanity/lib/queries";
import { loadQuery } from "../sanity/lib/load-query";
import { getDraftModeProps } from "../sanity/lib/draft-mode";
import Layout from "../layouts/Layout.astro";
const { data: posts } = await loadQuery<POSTS_QUERY_RESULT>({
query: POSTS_QUERY,
...getDraftModeProps(Astro.cookies),
});
---
<Layout>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/post/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</Layout>---
import type { POST_QUERY_RESULT } from "../../../sanity.types";
import { POST_QUERY } from "../../sanity/lib/queries";
import { loadQuery } from "../../sanity/lib/load-query";
import { getDraftModeProps } from "../../sanity/lib/draft-mode";
import Layout from "../../layouts/Layout.astro";
import PortableText from "../../components/PortableText.astro";
const { params } = Astro;
const { data: post } = await loadQuery<POST_QUERY_RESULT>({
query: POST_QUERY,
params,
...getDraftModeProps(Astro.cookies),
});
---
<Layout>
<h1>A post about {post.title}</h1>
<PortableText portableText={post.body} />
</Layout>Note: If you render queried content in <title> tags or <meta> descriptions, stega characters will be present during draft mode. This is harmless for editors (the characters are invisible), but if you want clean metadata even in draft mode, use stegaClean() from @sanity/client/stega.
Run both apps
With everything set up, run both apps to test. In separate terminal windows:
# Terminal 1: Start the Studio cd studio npm run dev # Runs on http://localhost:3333
# Terminal 1: Start the Studio cd studio pnpm run dev # Runs on http://localhost:3333
# Terminal 1: Start the Studio cd studio yarn run dev # Runs on http://localhost:3333
# Terminal 1: Start the Studio cd studio bun run dev # Runs on http://localhost:3333
# Terminal 2: Start the Astro frontend cd frontend npm run dev # Runs on http://localhost:4321
# Terminal 2: Start the Astro frontend cd frontend pnpm run dev # Runs on http://localhost:4321
# Terminal 2: Start the Astro frontend cd frontend yarn run dev # Runs on http://localhost:4321
# Terminal 2: Start the Astro frontend cd frontend bun run dev # Runs on http://localhost:4321
Open http://localhost:3333 in your browser and navigate to the Presentation Tool. You should see the Astro frontend loaded in the iframe with click-to-edit overlays on text elements.
The full flow
Now that you've seen every file, here's the complete sequence when an editor uses visual editing. This is the same flow described in "How the pieces fit together," but now you can trace each step back to the specific file that handles it:
- The editor opens the Presentation Tool in the Studio (
studio/sanity.config.ts). - The Studio loads
http://localhost:4321(theinitialURL) in an iframe and usesstudio/lib/resolve.tsto map the current document to a frontend URL. - The Studio hits
http://localhost:4321/api/draft-mode/enablewith authentication parameters (frontend/src/pages/api/draft-mode/enable.ts). - The enable route validates the secret via
validatePreviewUrl, sets the cookie, and redirects to the requested page. - The page re-renders.
getDraftModeProps(frontend/src/sanity/lib/draft-mode.ts) reads the cookie and passes the value toloadQuery(frontend/src/sanity/lib/load-query.ts).loadQueryfetches draft content with stega-encoded strings: each string value has invisible characters that encode the document ID, field path, and Studio URL (configured infrontend/astro.config.mjs). <SanityVisualEditing />(frontend/src/components/SanityVisualEditing.tsx, mounted viafrontend/src/layouts/Layout.astroonly during draft mode) reads the DOM, finds the stega-encoded strings, and renders transparent click-to-edit overlays on each text element.- The editor clicks an overlay. The overlay sends a
postMessageto the parent Studio window with the document ID and field path. The Studio navigates to that field. - The editor changes a field. The mutation propagates through the Content Lake. The
refreshcallback on<SanityVisualEditing />fires, triggeringwindow.location.reload(). The page re-fetches from the server with the updated draft content.
Next steps
- Deploy to production: Update
stega.studioUrlinastro.config.mjs, the Presentation ToolinitialURL instudio/sanity.config.ts, and your CORS origins to point to your deployed URLs instead oflocalhost. It's common to use environment variables for these values with local fallbacks. Make sure the cookie values meet your security standards. - Add more document types to `resolve.ts`: Any document type that has a corresponding frontend route can get visual editing. Add entries to the
locationsobject for each type.
Troubleshooting
Overlays appear but clicking does nothing
Cause: stega.studioUrl is missing from the @sanity/astro integration config in frontend/astro.config.mjs.
Fix: Add stega: { studioUrl: 'http://localhost:3333' } to the sanity() integration options.
Presentation Tool shows a blank iframe
Cause: initial is missing from the Presentation Tool config in studio/sanity.config.ts. This only happens when the Studio and frontend run as separate apps.
Fix: Add initial: 'http://localhost:4321' to previewUrl in the presentationTool() config.
Live preview doesn't update, 403 errors in browser console
Cause: The frontend's origin is missing from the Sanity project's CORS settings, so the browser can't reach the Content Lake.
Fix: Add http://localhost:4321 (with Allow credentials checked) in your project's CORS settings at sanity.io/manage under API → CORS Origins.
String comparisons fail in Draft Mode
Cause: Stega encoding adds invisible characters to string values. An equality check like align === 'center' returns false even when the visible value is "center" because the encoded string contains extra characters.
Fix: Use stegaClean() to strip the encoding before comparing:
import { stegaClean } from "@sanity/client/stega";
const cleanAlign = stegaClean(align);
if (cleanAlign === "center") {
// ...
}Module resolution errors in development
Cause: Vite's dev server fails to pre-bundle certain dependencies used by @sanity/visual-editing and its transitive imports.
Fix: Add the problematic modules to vite.optimizeDeps.include in astro.config.mjs:
vite: {
optimizeDeps: {
include: [
"react/compiler-runtime",
"lodash/isObject.js",
"lodash/groupBy.js",
"lodash/keyBy.js",
"lodash/partition.js",
"lodash/sortedIndex.js",
],
},
},This is a known dev-server issue that may be resolved in a future release of @sanity/astro.
Draft mode not activating
Cause: The browser blocks the cookie because it requires SameSite=None; Secure, which in turn requires HTTPS (or localhost).
Fix: Ensure you're accessing the frontend via localhost (not an IP address or custom domain) during development. For deployed environments, ensure HTTPS is enabled.
Page titles contain garbled text in Draft Mode
Cause: If you render queried content in <title> or <meta> tags, stega characters will be embedded in them.
Fix: Use stegaClean() from @sanity/client/stega to strip encoding before inserting into metadata:
import { stegaClean } from "@sanity/client/stega";
// In your .astro frontmatter:
const cleanTitle = stegaClean(post.title);Then use cleanTitle in the <title> tag.
Reference
Key packages
sanity(5.x): Sanity Studioastro(6.x): Astro framework@sanity/astro(3.x): Sanity integration for Astro (client, stega config)@astrojs/react(5.x): React support for client-side components@astrojs/node(9.x): Node.js server adapter@sanity/visual-editing(5.x): Visual editing overlays and hooks@sanity/preview-url-secret(latest): Preview URL validation for draft modegroq:defineQueryfor typed GROQ queries@sanity/image-url(2.1.x): Image URL generationastro-portabletext(0.x): Portable Text rendering for Astro
File map
Every file involved in the visual editing integration, what it does, and what it depends on:
studio/sanity.config.ts(Configures the Presentation Tool with the frontend's initial URL andpreviewMode.enablepath):studio/lib/resolve.tsstudio/lib/resolve.ts(Maps document types to frontend URLs for iframe navigation and location badges): Schema type names, frontend route structure insrc/pages/frontend/astro.config.mjs(Astro config: SSR,@sanity/astrointegration withstega.studioUrl, React, Vite optimizeDeps):PUBLIC_SANITY_PROJECT_ID,PUBLIC_SANITY_DATASETfrontend/src/env.d.ts(Triple-slash references forastro/clientand@sanity/astro/moduletype definitions): Nothingfrontend/src/sanity/lib/draft-mode.ts(Reads draft mode and perspective cookies fromAstro.cookies): Nothingfrontend/src/sanity/lib/load-query.ts(Fetches content with perspective/stega switching based on draft mode):sanity:client,SANITY_API_READ_TOKENfrontend/src/sanity/lib/queries.ts(Centralized GROQ queries wrapped indefineQuery):@sanity/astrofrontend/src/components/SanityVisualEditing.tsx(History adapter, perspective cookie sync, content refresh via page reload):@sanity/visual-editing/react- frontend/src/components/DisableDraftMode.tsx (Floating button to exit draft mode, hidden when inside the Presentation Tool):
@sanity/visual-editing/react frontend/src/layouts/Layout.astro(Shared layout: conditional visual editing components in draft mode):SanityVisualEditing.tsx,DisableDraftMode.tsxfrontend/src/pages/api/draft-mode/enable.ts(Validates Presentation Tool secret, sets the perspective cookie):sanity:client,@sanity/preview-url-secret,SANITY_API_READ_TOKENfrontend/src/pages/api/draft-mode/disable.ts(Clears cookies, redirects to homepage): Nothing