.astro components doing the
rendering, no islands added and nothing ported to React.
astro.config.ts
/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
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():
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.
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. Putdata-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:
astro:after-swap, thenastro:page-load— the pair<ClientRouter />fires after it swaps a page, so a component already written for view transitions re-binds with no change;avocado:refresh, aCustomEventwhosedetail.rootis the element whose contents were replaced (document.bodyfor a body swap).
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 withshared: 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 ownAstro. 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:
{}, 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:
<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, soastro build --mode stagingreads.env.staging. - It copies only
DRAFT_MODE_SECRET,ORCHESTRATOR_URL,ORCHESTRATOR_ACCESS_TOKEN,PUBLISH_TOKEN,EDITOR_CORS_ORIGINS,AVOCADO_SITE_IDand a few aliases. Your CMS tokens stay inimport.meta.env, where you put them. - A variable already in the environment always wins, even an empty one.
AVOCADO_FORCE_EDITORis 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.
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 aneditorOrigin 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 appendeditorQueryto your links by hand — thoughAstro.locals.avocado.editorQueryis 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’sastro:before-preparationevent, 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 ownnavigate()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 }.
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 inastro 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':
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 isastro >=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 checkneeds@astrojs/checkand TypeScript 6. Against TypeScript 7 it stops with “The TypeScript module loaded (found 7.x) does not expose the programmatic API thatastro checkrelies on”. Installtypescript@^6as a dev dependency for the check.astro devdaemonises itself when it has no terminal. Started from a script or an agent’s shell, it prints “Dev server running at …” and returns, sonohup astro dev > dev.logcaptures only that line. Read the server’s output withnpx astro dev logs, and stop it withnpx 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.