Visual Editing

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.

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.

Astro and the Live Content API

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/astro v3.3.1 or later, @astrojs/react v5+, and @astrojs/node v9+.
  • 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 APITokens 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 with the following command:

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:

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 initial field 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, 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.
  • 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's refresh callback triggers a full page reload. The page re-fetches from the server with the updated draft content.

Contracts between the two apps.

Environment variables

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

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:

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 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.

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 APICORS Origins, or add it with the CLI:

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

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. This is a known issue that @sanity/astro may 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:

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:

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:

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.

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:

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:

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.

Draft mode routes

These two routes are the bridge between the Studio and the frontend.

Enable route:

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: true allows 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: true required when sameSite is "none".

In Next.js, defineEnableDraftMode from next-sanity/draft-mode wraps this logic. In Astro, we use validatePreviewUrl directly.

Disable route:

This clears the cookie and redirects to the homepage. It's called by the "Disable Draft Mode" button.

The "Disable Draft Mode" button

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).

Run both apps

With everything set up, run both apps to test. In separate terminal windows:

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 (the initial URL) in an iframe and uses studio/lib/resolve.ts to map the current document to a frontend URL.
  • The Studio hits http://localhost:4321/api/draft-mode/enable with 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 to loadQuery (frontend/src/sanity/lib/load-query.ts). loadQuery fetches draft content with stega-encoded strings: each string value has invisible characters that encode the document ID, field path, and Studio URL (configured in frontend/astro.config.mjs).
  • <SanityVisualEditing /> (frontend/src/components/SanityVisualEditing.tsx, mounted via frontend/src/layouts/Layout.astro only 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 postMessage to 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 refresh callback on <SanityVisualEditing /> fires, triggering window.location.reload(). The page re-fetches from the server with the updated draft content.

Next steps

  • Deploy to production: Update stega.studioUrl in astro.config.mjs, the Presentation Tool initial URL in studio/sanity.config.ts, and your CORS origins to point to your deployed URLs instead of localhost. 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 locations object 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 APICORS 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 Studio
  • astro (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 mode
  • groq: defineQuery for typed GROQ queries
  • @sanity/image-url (2.1.x): Image URL generation
  • astro-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 and previewMode.enable path): studio/lib/resolve.ts
  • studio/lib/resolve.ts (Maps document types to frontend URLs for iframe navigation and location badges): Schema type names, frontend route structure in src/pages/
  • frontend/astro.config.mjs (Astro config: SSR, @sanity/astro integration with stega.studioUrl, React, Vite optimizeDeps): PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET
  • frontend/src/env.d.ts (Triple-slash references for astro/client and @sanity/astro/module type definitions): Nothing
  • frontend/src/sanity/lib/draft-mode.ts (Reads draft mode and perspective cookies from Astro.cookies): Nothing
  • frontend/src/sanity/lib/load-query.ts (Fetches content with perspective/stega switching based on draft mode): sanity:client, SANITY_API_READ_TOKEN
  • frontend/src/sanity/lib/queries.ts (Centralized GROQ queries wrapped in defineQuery): @sanity/astro
  • frontend/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.tsx
  • frontend/src/pages/api/draft-mode/enable.ts (Validates Presentation Tool secret, sets the perspective cookie): sanity:client, @sanity/preview-url-secret, SANITY_API_READ_TOKEN
  • frontend/src/pages/api/draft-mode/disable.ts (Clears cookies, redirects to homepage): Nothing

Was this page helpful?