# Visual Editing with Astro

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.

> [!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](https://www.sanity.io/docs/developer-guides/sanity-astro-blog).

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.

## 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](https://www.sanity.io/manage) if you don't have one.
- [An API token](https://www.sanity.io/docs/content-lake/http-auth) with **Viewer** permissions for that project. Create one under **API** → **Tokens** in your project settings.
- `http://localhost:4321` added as a [CORS origin](https://www.sanity.io/docs/content-lake/browser-security-and-cors) 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:

```sh
npm install @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:

```sh
pnpm 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:

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.

## Environment variables

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

**frontend/.env**

```sh
PUBLIC_SANITY_PROJECT_ID=your-project-id
PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your-viewer-token
```

**studio/.env**

```text
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](https://www.sanity.io/docs/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`:

**studio/sanity.config.ts**

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

**studio/lib/resolve.ts**

```typescript
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](https://www.sanity.io/manage) under **API** → **CORS Origins**, or add it with the CLI:

```sh
npx 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

**frontend/astro.config.mjs**

```typescript
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. 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:

**frontend/src/env.d.ts**

```typescript
/// <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:

```typescript
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`:

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

```typescript
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:

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

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

### 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:

**frontend/src/sanity/lib/queries.ts**

```typescript
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:

**frontend/src/layouts/Layout.astro**

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

**frontend/src/components/SanityVisualEditing.tsx**

```tsx
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:**

**frontend/src/pages/api/draft-mode/enable.ts**

```typescript
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**: `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:**

**frontend/src/pages/api/draft-mode/disable.ts**

```typescript
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

**frontend/src/components/DisableDraftMode.tsx**

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

**frontend/src/pages/index.astro**

```html
---
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>
```

**frontend/src/pages/post/[slug].astro**

```html
---
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]
> **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:

```sh
# Terminal 1: Start the Studio
cd studio
npm run dev
# Runs on http://localhost:3333
```

```sh
# Terminal 2: Start the Astro frontend
cd frontend
npm 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:

1. The editor opens the **Presentation Tool** in the Studio (`studio/sanity.config.ts`).
2. 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.
3. The Studio hits `http://localhost:4321/api/draft-mode/enable` with authentication parameters (`frontend/src/pages/api/draft-mode/enable.ts`).
4. The enable route validates the secret via `validatePreviewUrl`, sets the cookie, and redirects to the requested page.
5. 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`).
6. `<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.
7. 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.
8. 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](https://www.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:

```typescript
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`:

```javascript
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:

```typescript
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

