> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Astro Integration

> Add Avocado to an Astro site with one integration in astro.config.ts. The site keeps rendering its own .astro components — no React islands, no rewrite.

Avocado ships an Astro integration. Install it, name your content module, and the
editor can open your site — with your own `.astro` components doing the
rendering, no islands added and nothing ported to React.

```bash theme={null}
npm install @avocadostudio-ai/astro @avocadostudio-ai/site-sdk
```

```ts astro.config.ts theme={null}
import { defineConfig } from 'astro/config'
import avocado from '@avocadostudio-ai/astro'

export default defineConfig({
  output: 'static',
  integrations: [
    avocado({
      siteId: 'my-site',
      content: './src/avocado/content.ts',
      editablePages: ['src/pages/index.astro'],
    }),
  ],
})
```

That is the whole of the wiring. The integration mounts the five `/api/editor/*`
routes, loads the Avocado variables from your `.env`, injects a 3 KB loader that
fetches the preview bridge only inside the editor's frame, and makes the pages
you named renderable on demand in `astro dev`.

<Note>
  **Nothing from React or Next is installed.** `next`, `react` and `react-dom` are
  optional peers of `@avocadostudio-ai/site-sdk` and `@avocadostudio-ai/blocks`,
  and no entry point an Astro site imports reaches them. Through 0.21 they were
  required, and npm 7+ installed 217 MB of Next into an Astro project; if you added
  `legacy-peer-deps=true` to `.npmrc` to stop that, you can remove it.
</Note>

## The site renders itself

This is the mode Avocado calls **site-renders-itself**, and it is what makes
Astro workable. Avocado supplies the schema, the draft props, the editable
markers and publishing. Your components render. Nothing about your template
changes shape.

What you provide is a **content module** — one default export saying where your
content lives:

```ts src/avocado/content.ts theme={null}
import type { PageDoc } from '@avocadostudio-ai/site-sdk'
import { FIELD_TABLE } from './field-table'
import { getPage, getSlugs, writePage } from './pages'
import { registerFieldTable, suffixNaming } from '@avocadostudio-ai/site-sdk/lens'

export default {
  getPages: async (): Promise<PageDoc[]> => { /* read your content */ },
  // A function, called per request — not a module side effect. The field
  // registry lives on globalThis, and a transitive import of Avocado's own
  // defaults re-registers those on top of yours with nothing reporting the
  // loss. On Astro a route module is evaluated on the first request to that
  // route, so which registration wins would otherwise depend on which route
  // the editor asks for first. A table that declares an image must say what
  // its two props are called; plain JSON keeps `image` and `image_alt`.
  registerBlocks: () =>
    registerFieldTable(FIELD_TABLE, { primitives: { imageNaming: suffixNaming('', '_alt') } }),
  blockTypes: Object.keys(FIELD_TABLE),
  onPublish: async (pages: PageDoc[]) => {
    for (const page of pages) await writePage(page)
    return { ok: true }
  },
}
```

On a static template, `onPublish` writing the page back to a file under `src/`
is not a limitation to work around — it is what "published" means. The file is
committed, reviewed as a diff, and the site rebuilt from it. When the copy is
still written inline in your `.astro` markup, [file-backed
sites](/integration/file-backed-sites) is the recipe for moving it into files
without touching the markup or the styles.

Types come from the SDK's root — `import type { PageDoc, BlockInstance } from
'@avocadostudio-ai/site-sdk'`. `@avocadostudio-ai/shared` is a dependency of the
SDK, not a package your source imports, which is why it is not in the install
line.

The content module is the editor API's whole configuration, so `publishSecret`
and `maxPagesRemoved` go here too. The publish route refuses a payload that
would remove every page with a 409 unless the body carries `allowDelete: true` —
an empty `pages` array is far more often a client that failed to load its own
state than somebody deleting their site — and it refuses an unconfigured publish
with a 401 under `NODE_ENV=production`. The injected route only exists during
`astro dev` or in a build with an adapter, so the production refusal matters only
if you serve it.

<Note>
  The editor API route cannot live in your `src/pages`. It has to render on
  demand, and `astro build` fails on an on-demand route with no adapter whether or
  not anyone intends to serve it. The integration injects the route instead, which
  is why it needs `content` as a path rather than as a value.
</Note>

## `editablePages` — why it is a list

The pages you name render on demand during `astro dev` and are prerendered in a
build. Both halves are needed.

A prerendered route has no request: Astro strips its query string and gives it
no headers, so no `__editor` parameter or draft cookie can reach it, and the
middleware resolves every such render as a visitor's without reading the
request at all (which is also why a static build prints no
`Astro.request.headers` warning for it). The preview then renders the *published* page —
correctly, and with no editable markers — which looks exactly like an
integration nobody wired up.

Your site cannot fix that itself. `export const prerender = false` on the page
makes `astro build` fail with `NoAdapterInstalled`, and `!import.meta.env.DEV`
is not a literal by the time Astro's route analysis reads it, so the route stays
prerendered regardless.

It is a list rather than "every page" because a route built from
`getStaticPaths` cannot render on demand at all: its `Astro.props` come from the
path it was generated for, so on demand they are `undefined` and the route
throws on the first property it reads. Naming the pages leaves paginated and
collection routes exactly as they were. `*` matches within a path segment, `**`
across segments.

**In a build this does nothing at all.** Every route is prerendered and the
output is as static as it was before Avocado was installed.

## Rendering the draft

`getPages` is your **published** source. An editor render needs the draft, and
that is `Astro.locals.avocado.getDraftPage()`:

```astro theme={null}
---
import { getPublishedPage } from "../avocado/pages"

const { avocado } = Astro.locals
const page = (await avocado.getDraftPage()) ?? (await getPublishedPage("/"))
---
```

That fallback is the whole contract. `getDraftPage()` returns `null` when the
orchestrator has no draft for this slug or could not be reached, and in both
cases the right answer is your published content. It defaults to the request's
own path; pass a slug when the route renders a page it does not share a URL with.
Nothing is fetched unless a render calls it, and the result is memoised per
request per slug.

<Warning>
  **Skip it and the preview never updates, while everything else reports success.**
  The preview bridge refreshes by re-fetching the page and swapping the rendered
  subtree, not by patching the DOM — so a render that reads your published file
  answers every edit with byte-identical HTML. Markers emit, the outline draws, selection
  works, the property panel loads and accepts typing, and the iframe never moves.
  This was found on the pilot, and the conclusion it leads to is "the bridge is
  broken", which sends you reading the wrong file.
</Warning>

**A draft written before your block types changed.** Rename or split a block
type and every draft session still holds the old one, which your template
cannot draw. Pass the types the route renders, and such a draft resolves to
`null` as well, with `Astro.locals.avocado.staleDraft` set to
`{ slug, unknownTypes }`:

```astro theme={null}
---
const { avocado } = Astro.locals
const page =
  (await avocado.getDraftPage(undefined, { renders: ['hero', 'bookGrid', 'footer'] })) ??
  (await getPublishedPage('/'))
---
{avocado.staleDraft && <p class="banner">This draft is out of date — use "Pull this page" in the editor.</p>}
```

`renders` also takes a predicate, `(type) => boolean`. Without it the route
renders the draft as it comes — and on the integration that found this, the
route answered 404 inside the editor. The editor marks stale pages and offers
**Pull this page**, which replaces the draft with the published version.

When you need to tell an editor *why* a draft is missing — "the orchestrator is
unreachable" rather than a silently stale page — `resolvePageRender` from
`@avocadostudio-ai/site-sdk/page/core` returns a `draft-unavailable` outcome that
`getDraftPage` collapses into `null`. It resolves navigation and site chrome as
well, which a template rendering its own header will want to ignore.

### Preview refresh

A refresh swaps the part of the page that holds the blocks rather than reloading
the document. Put `data-avocado-root` on the element that encloses every block —
it matters when a block lives outside `<main>`, as a header or footer block
does.

The subtree is `[data-avocado-root]` if it holds every block, then `<main>` if
it does, then everything inside `<body>`. When your header and footer are blocks
the element that encloses them all is `<body>` itself, and
`<body data-avocado-root>` is fine: a body is always swapped by its
**children**, never replaced. The body element stays, with its attributes as
your scripts left them — a `dark-mode` class a toggle put on it survives every
refresh — and the markup inside it comes from the new render.

Swapped markup takes the listeners with it. A script that bound a click handler
to the mobile nav button or the theme toggle is holding a node that is no longer
in the page, and a refresh runs no scripts. So after each refresh the bridge
dispatches, on `document`:

1. `astro:after-swap`, then `astro:page-load` — the pair `<ClientRouter />`
   fires after it swaps a page, so a component already written for view
   transitions re-binds with no change;
2. `avocado:refresh`, a `CustomEvent` whose `detail.root` is the element whose
   contents were replaced (`document.body` for a body swap).

Re-bind on whichever one your script already knows. Listen to one of them, not
to both, or the handler is bound twice:

```astro theme={null}
<script>
  function bindThemeToggle() {
    document.querySelector("#theme-toggle")?.addEventListener("click", () => {
      document.body.classList.toggle("dark-mode")
    })
  }
  bindThemeToggle()
  // Also fired by <ClientRouter /> on navigation, and by the editor's refresh.
  document.addEventListener("astro:after-swap", bindThemeToggle)
</script>
```

A listener on `document` or `window` itself survives a refresh and needs
nothing. Neither does a page without any script of its own.

In selection mode a click on a link inside a block selects the block and does
not follow the link, so a block made of anchors — a CTA, a row of nav links, a
footer full of contact details — can be selected by clicking it. With selection
mode off, links navigate inside the preview and keep the editor's parameters.

### Header, footer and other site-wide content

A section every page renders — the header, the footer, the business address, a
closing CTA — is one block with `shared: true`, injected into every `PageDoc`
by `getPages` under the same id and written back once by `onPublish`. Keep it
inside `[data-avocado-root]` so a refresh swaps it too. See
[Site-wide content](/integration/cms-adapters#site-wide-content).

## Marking a field editable

Take the markers from the component's own `Astro`. The middleware has already
put the editor flag on `Astro.locals`, and every `.astro` file can read it, so
a component shared with the public pages needs no `editable` prop from its
parent:

```astro theme={null}
---
import { editorMarkers } from '@avocadostudio-ai/astro/markers'

const { block, field, scope } = editorMarkers(Astro)
const { book } = Astro.props
---
<article {...block(book.id, 'bookHero')}>
  <h1 {...field('title', { kind: 'text' })}>{book.title}</h1>
  <div class="cover" {...field('coverImage', { kind: 'image' })}>
    <img src={book.coverImage} alt={book.coverAlt} />
  </div>
</article>
```

On a public render all three return `{}`, so the page carries exactly the markup
it had before Avocado — no classes, no data attributes, no view-transition
names. The one exception is a scope asked for `{ display: 'contents' }`, which
keeps that style so the wrapper lays out the same in both renders.

A block's wrapper that already has a `class` or `style` of its own passes it to
`block()` rather than writing it beside the spread:

```astro theme={null}
<section {...block(section.id, 'hero', { class: 'cs-hero' })}>
```

Astro renders a literal attribute and a spread one side by side, so
`<section class="cs-hero" {...block(…)}>` produces two `class` attributes and
the browser keeps only the first — either your styling or the editor's
selection class is lost. Passed in, they are merged into one attribute:
`cs-hero editor-selectable` in the editor, and exactly `cs-hero` on a public
render. `style` works the same way. `blockProps(Astro, id, type, { class })`
takes the same fourth argument.

`blockProps(Astro, id, type)`, `editableProps` and `editableScopeProps` are
still exported, and `blockProps` still takes a boolean in place of `Astro`.

## Environment variables

Astro loads `.env` into `import.meta.env` and nowhere else, while the Avocado
runtime — the draft-secret check, the draft fetch, the publish token, the CORS
list — reads `process.env`. **The integration copies the Avocado keys across
itself**, in the Astro process, so `DRAFT_MODE_SECRET` and `ORCHESTRATOR_URL` in
`.env` simply work under `astro dev`, `astro build` and `astro preview`. You do
not need a `loadEnv` call in `astro.config`.

* It reads the files Vite reads, in Vite's order — `.env`, `.env.local`,
  `.env.<mode>`, `.env.<mode>.local` — for the mode Vite runs in, so
  `astro build --mode staging` reads `.env.staging`.
* It copies only `DRAFT_MODE_SECRET`, `ORCHESTRATOR_URL`,
  `ORCHESTRATOR_ACCESS_TOKEN`, `PUBLISH_TOKEN`, `EDITOR_CORS_ORIGINS`,
  `AVOCADO_SITE_ID` and a few aliases. Your CMS tokens stay in
  `import.meta.env`, where you put them.
* A variable already in the environment always wins, even an empty one.
* `AVOCADO_FORCE_EDITOR` is never read from a file: it turns every render into
  an editor render, and belongs to the CI job that sets it for one build.

**A standalone server reads its own environment.** `node dist/server/entry.mjs`
is a separate process that never runs the integration, so on a deployment set
the variables the way the platform delivers secrets — or start it with
`node --env-file=.env dist/server/entry.mjs`.

## Does the site need `output: 'server'`?

**No.** What a preview needs is for the page being previewed to render **on
demand**, per request: the draft, the editor's query and the draft cookie all
arrive with the request, and a prerendered page has none. Astro decides that per
route, so there are three workable shapes:

| Where you edit | What it needs |
| - | - |
| Against `astro dev` (content in your repo, publish writes files, rebuild to deploy) | Only `editablePages`, naming the pages to preview; the integration makes those render on demand in dev. `output: 'static'`, no adapter. |
| A deployed site, some pages previewable | An adapter, and `export const prerender = false` on each previewable page. `output` stays `'static'`; every other page is still a file. This is what `examples/astro-site` does. |
| A deployed site, every page previewable | An adapter and `output: 'server'`. Simplest when most pages are editable. |

A route that is on demand in production reads its content per request — so on a
CMS-backed site, publishing is visible without a rebuild on those routes, and
only on those. The Contentful bookshelf moved to `output: 'server'` with
`@astrojs/node` for exactly that reason, not because the integration requires
it. The editor API route (`/api/editor/*`) is injected only under `astro dev` or
in a build with an adapter; a static build without one contains no Avocado
route at all.

## Inside the editor frame

The loader injected into every page checks one thing — is this page framed,
with an `editorOrigin` on its URL — and on a visitor's page does nothing else.
Inside the editor's frame it does three things before it fetches the bridge:

* **Keeps links in the preview.** A same-origin link clicked in the frame gets
  the editor's parameters (`__editor`, `session`, `siteId`, `editorOrigin`,
  `secret`) before the browser follows it, so the next page is the draft. You
  no longer need to append `editorQuery` to your links by hand — though
  `Astro.locals.avocado.editorQuery` is still there, and harmless.
* **Stands `<ClientRouter />` down.** Client-side navigation fetches the next
  page with the URL it was given and swaps it in, which inside the frame renders
  the published page and carries the overlay across a swap it never agreed to.
  The loader cancels the router's `astro:before-preparation` event, which Astro
  turns into an ordinary navigation — to a URL that now carries the editor's
  parameters. That covers links, forms, back and forward, and your own
  `navigate()` calls. Nothing changes for visitors, who keep view transitions.
* **Hides Astro's dev toolbar.** It rendered inside the frame over the bottom of
  the page, covering whatever block was there. It stays on everywhere else; you
  no longer need `devToolbar: { enabled: false }`.

A `GET` form submitted inside the preview replaces the query string, and with it
the editor's parameters, so the result renders as a visitor's; a `POST` form
becomes an ordinary navigation. Neither is something an editor usually does.

## The preview bridge and your visitors

The bridge is about 146 KB (38 KB gzipped). Visitors never download it: what
every page carries is the loader, about 3 KB (1.4 KB gzipped) — most of it
Vite's module-preload helper — and the bridge is a separate chunk requested
only from inside the editor's frame. `pnpm test:astro` fails if a published
page's scripts exceed 10 KB or include the bridge. (Through 0.21 the bridge
itself was injected into every page, for every visitor.)

To ship no Avocado bytes to visitors at all, set `bridge: false` and start the
bridge from a layout only on editor renders:

```astro theme={null}
---
const { avocado } = Astro.locals
---
{avocado.isEditor && (
  <script>
    import { startAvocadoBridge } from '@avocadostudio-ai/astro/bridge'
    startAvocadoBridge({ allowedOrigins: ['https://editor.example.com'] })
  </script>
)}
```

`startAvocadoBridge` does the frame preparation above itself when the loader
has not, and pass it the same `editorOrigins` you gave the integration:
the bridge refuses an origin the list does not name.

## Production

Everything above works in `astro dev` with nothing configured. A deployment that
serves on-demand routes has three things to set, and all three are inert in
development — so none of them can be checked by the loop you develop in. Set
them in the deployment's own environment: [`.env`](#environment-variables) is read by the Astro
process, not by a standalone server.

| Variable | What it does |
| - | - |
| `DRAFT_MODE_SECRET` | Authorizes a draft render, and gates `/api/editor/draft` — which answers **503** `DRAFT_MODE_SECRET is not set` while it is empty |
| `PUBLISH_TOKEN` | Required by `POST /api/editor/publish`, which otherwise refuses every request under `NODE_ENV=production` |
| `ORCHESTRATOR_URL` | Where `getDraftPage()` reads drafts from |

**`__editor=1` authorizes nothing.** It is a routing hint the editor puts on the
iframe URL — no secret in it — so under `NODE_ENV=production` a request carrying
only the parameter is rendered as an ordinary visitor's. Two things authorize: the
signed cookie `/api/editor/draft?secret=…` mints, and a valid `secret` on the
request itself, which is what covers the case the cookie cannot — the editor
renders your site in a cross-origin iframe, where third-party cookies are
frequently blocked outright. Development is unchanged; refusing there would mean
configuring a secret before a local preview could render anything.

**`editorOrigins` is enforced in three places.** The one list in
`astro.config.ts` decides the `postMessage` target, which frame may drive inline
edits, *and* which origin `/api/editor/*` answers cross-origin. You do not need
`EDITOR_CORS_ORIGINS` as well; it still works and is additive.

Both halves check it. The server resolves the origin on the URL against the list
and degrades an unlisted one to your first entry, so the page still renders; the
preview bridge refuses to attach at all and says so in the console, because a
frame naming an origin you never listed has nobody listening on the other side.

**In development your local editor is trusted whatever its port**, listed or
not — the editor's port moves, and requiring it in the list would mean editing
committed config to run the thing locally. So an origin mistake first shows up
in production, which is the argument for naming the real one before you deploy
rather than after.

## Describing your components

Your `.astro` widgets are described to Avocado with a
[field table](/integration/field-table) — one table giving the schema the
operations engine validates against and the metadata the property panel draws.

One kind matters more on Astro than anywhere else. A widget that takes a prop
either as a value or as a slot renders it with `set:html`, so the stored value
is a string of markup. That is `kind: 'html'`:

```ts theme={null}
const HEADLINE = {
  title: { kind: 'html', label: 'Title' },
  subtitle: { kind: 'html', label: 'Subtitle' },
}
```

Declaring it `richtext` instead is the mistake worth naming, because it compiles
and looks fine: `richtext` means *a document*, so the panel renders the markup
literally and a person editing it writes broken markup back into your source
file. See [when the stored value is HTML](/integration/field-table#when-the-stored-value-is-html).

A prop that is a decorative slot — a background `<div class="absolute inset-0 …">`
— is not a field at all. Leave it out of the table; an undeclared prop rides
through the ops engine untouched, which is how the page keeps it.

## Options

| Option | Default | What it does |
| - | - | - |
| `siteId` | — | The id the orchestrator keys drafts by. Also read from `AVOCADO_SITE_ID` |
| `content` | — | Path to the content module above, relative to the project root |
| `editablePages` | `[]` | Pages the editor may preview. On demand in dev, prerendered in a build |
| `editorOrigins` | — | Origins permitted to drive inline edits. An unlisted origin is accepted in dev, because the editor's port moves |
| `session` | `"dev"` | Orchestrator draft session |
| `bridge` | `true` | Inject the loader that fetches the preview bridge inside the editor's frame. `false` injects nothing; see [the preview bridge and your visitors](#the-preview-bridge-and-your-visitors) |

The editor API is always mounted at `/api/editor`. There was an option for that
and it has been removed: the other end of the contract is not configurable — the
editor fetches `/api/editor/blocks` and `/pages` from the browser and the
orchestrator POSTs `/api/editor/publish`, all spelled out — so moving only this
half mounted the API where nothing would call it, and the manifest and publish
answered 404 against a config that read correctly. A site that needs another
path mounts the route itself with `createAvocadoEditorApi({ basePath })`.

## On Astro 7

The integration's peer range is `astro >=5`, and 7.x works unchanged — there is
nothing to upgrade or downgrade. Two things about Astro 7 itself catch out the
verify step, a coding agent's especially:

* **`astro check` needs `@astrojs/check` and TypeScript 6.** Against
  TypeScript 7 it stops with *"The TypeScript module loaded (found 7.x) does not
  expose the programmatic API that `astro check` relies on"*. Install
  `typescript@^6` as a dev dependency for the check.
* **`astro dev` daemonises itself when it has no terminal.** Started from a
  script or an agent's shell, it prints *"Dev server running at …"* and returns,
  so `nohup astro dev > dev.log` captures only that line. Read the server's
  output with `npx astro dev logs`, and stop it with `npx astro dev stop` — not by
  killing the process you launched, which has already exited.

## A worked example

`examples/astro-site` in the repository is the smallest site that exercises the
whole contract: two pages plus one with no `<main>`, a block rendered outside
`<main>`, a page with `<ClientRouter />`, a page with `data-avocado-root` on
`<body>` and a script that keeps a class on it, a list whose rows are drawn by
their own component, a `stringList` edited in place, a link inside a block, a
`kind: 'html'` headline, components that take their markers
from `editorMarkers(Astro)` with no `editable` prop anywhere, and an
`onPublish` that writes JSON back under `src/`.

Two gates drive it, and they divide along the only line that matters here —
whether a browser is running the page.

| | | |
| - | - | - |
| `pnpm test:astro` | `scripts/astro-check.mjs` | Builds it, serves it under `NODE_ENV=production`, asserts on the bytes: the draft gate, the origin allowlist, the cookie, 100% marker coverage, the publish round trip, and that a visitor downloads the loader and not the bridge. 30 checks. |
| `pnpm test:astro:bridge` | `scripts/astro-bridge-check.mjs` | Frames the preview cross-origin from a stub parent speaking `site-editor/v1` and drives it in Chromium: click-to-select, refresh, navigation, inline editing, links without the editor query, links inside a block in and out of selection mode, `<ClientRouter />` navigation, a body-rooted refresh and the events it fires, the hidden dev toolbar. 17 checks. |

## What is not here yet

Astro support shipped from one pilot integration, and the fixture above is not a
second one — it proves the contract, not that the integration survives contact
with a real template. Expect to find gaps around anything neither exercises, and
say so when you do — that is worth more to us than a clean report.

Inside the editor's frame `<ClientRouter />` no longer swaps at all — the
browser gate drives a `navigate()` call on the fixture's `/routed` page and
asserts a full navigation to the draft — so the bridge's re-attachment on
`astro:page-load` is a fallback for a page that swaps some other way, and that
path is still verified by construction only.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.