Skip to main content
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.
astro.config.ts
That is the whole of the wiring. The integration mounts the five /api/editor/* routes, injects the preview bridge, and makes the pages you named renderable on demand in astro dev.

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:
src/avocado/content.ts
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. 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.
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.

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.request.headers is empty, so the middleware sees no __editor parameter and no draft cookie, and resolves every request as a visitor’s. 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():
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.
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.
The subtree a refresh swaps is [data-avocado-root] if your template has one, then <main>, then the whole <body>. Put data-avocado-root on the element that encloses every block when your blocks live outside <main> — a header or footer block does — or when you want the swap narrower than the body. 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.

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. __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.
Astro’s dev toolbar renders inside the editor’s iframe — a floating pill over the bottom of the page, covering whatever block is down there. Set devToolbar: { enabled: false } in astro.config.ts while you are editing, or expect to lose the footer behind it.

Describing your components

Your .astro widgets are described to Avocado with a 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':
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. 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

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

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 list whose rows are drawn by their own component, a kind: 'html' headline, 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.

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. Re-attachment after a <ClientRouter /> swap is still verified by construction: the fixture does not use the client router, so astro:page-load and astro:before-swap are exercised only by the initial-load path. Everything else in the bridge is now driven in a browser on every push.