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

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. When the copy is still written inline in your .astro markup, 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.
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 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():
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.
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 }:
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:
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. 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.

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:
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 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: 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:
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 is read by the Astro process, not by a standalone server. __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 — 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 }).

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.

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.