Skip to main content
This page is the reference, not a tutorial. It states what the integration must satisfy, so you can read it before delegating the work, or review what an agent produced against it. End state. A site registered with the orchestrator, your existing pages serving from your existing routes, your own components exposing exactly the props you declared as content, and a coverage number that proves it.
Who writes the code. Almost nobody wires this by hand end to end, and we do not recommend it. Hand it to the coding agent that already knows your codebase — see your own coding agent — and use this page to review what comes back. The contract is the same either way.

Prerequisites

  • Node 22+ and your usual package manager.
  • A Next.js 15 or 16 project on the App Router. On Astro 5+, @avocadostudio-ai/astro satisfies this contract for you — the seams below still describe what it is doing, but you do not implement them. Any other framework can satisfy the same contract through the SDK’s framework-agnostic /core primitives — see Non-Next.js integration — but you would be a first mover.
  • An Avocado orchestrator running, locally or hosted. It defaults to http://localhost:4200.
  • An ANTHROPIC_API_KEY, OPENAI_API_KEY or GOOGLE_GENAI_API_KEY on the orchestrator. Avocado runs on your keys and never resells tokens.

Before you write anything: the survey

Every item below was missed by an integration that otherwise followed this page, and found only when a person opened the Studio. Look for each one first and write down what you found. The toolchain. Record the Node version, the package manager and whether corepack pins one (a packageManager field here or in a parent directory) before the first install. Check free disk against what the install adds — library mode’s @avocadostudio-ai/orchestrator-core is about 66 MB of dependencies, and split mode does not need it. Next.js older than 15 or Astro older than 5 is an upgrade to do first, not during. Sections, not entries. List the components each route renders and map one block per rendered section — even when several sections read the same CMS entry. A blog post that is one entry rendered as a hero, a body and a related-posts grid is three blocks. Mapped as one, a click anywhere selects the whole post and the panel shows every field. Known blockers. Keep the site’s own data layer. Fetch pages the way the site already does and overlay only the fields Avocado edits, rather than rewriting templates to read Avocado’s projected props — otherwise every production read depends on the lens, and the site’s original data module is left behind as dead code. A Next site with a separate preview route re-implements the page composition there; keep it in step with the public route, because nothing else will.

The six seams

1. The editor API — one catch-all route

Mount createEditorApiHandler from @avocadostudio-ai/site-sdk/routes at app/api/editor/[...path]/route.ts, exporting GET, POST and OPTIONS. That single handler must serve all five endpoints, and the editor calls them at exactly these paths:
Do not hand-write these. Secret validation and the internal-redirect check are security-critical and easy to get subtly wrong — an open redirect on /api/editor/draft is a real vulnerability. The helper does both, plus the draft cookie and CORS preflight.
Options worth knowing: registerBlocks (runs your registrations at request time, so bundler import order cannot clobber them), blockTypes (narrows the manifest to the types this site actually renders, or an object { sections, elements, hidden, groups } that also shapes the add-block picker), getManifest (full override), getSiteConfig, editorOrigins (editor origins allowed by CORS, added to EDITOR_CORS_ORIGINS), onPublish, publishSecret, maxPagesRemoved. Declare the site’s languages in getSiteConfig, even when there is one: { locales: ["en-US"], defaultLocale: "en-US" }. With them, a request to write in a language the site does not have produces a question instead of a translation. Publishing is guarded twice, and both guards fail closed.
  • The secret is not optional in production. publishSecret is usually process.env.PUBLISH_TOKEN, and the orchestrator sends the same value as x-publish-token. With no secret configured, the route answers 401 under NODE_ENV=production and names the variable in the response; on your own machine it stays open, because publishing to it is the point, and warns once. An optional guard on an endpoint that overwrites a site’s content is not a guard.
  • A publish may not remove every page. The only validation this route used to do was Array.isArray(body.pages), and [] is an array — so a client that failed to load its own state could replace the whole site with nothing and get {"ok":true} back. Emptying the site now needs "allowDelete": true in the body and is otherwise a 409 that says how many pages it protected. Removing one page of three is still an ordinary edit; set maxPagesRemoved if you want a tighter bound than “not all of them”. A site that is already empty may still publish empty, so a new integration’s first publish is not refused.
The rule is exported as checkDestructivePublish from @avocadostudio-ai/site-sdk/routes if you want to apply it somewhere else. → Next.js integration

2. The page factory

Replace app/[[...slug]]/page.tsx with createSitePage from @avocadostudio-ai/site-sdk/page, wired to your own getPage, getSlugs and getSiteConfig. It must export three things:
  • default Page — the route component.
  • generateStaticParams — your slugs.
  • generateMetadata — not optional in practice. Without it every page inherits the root layout’s <title>, with no description and no social card. The SDK derives all three from the page, but Next only reads them if the route file exports the function.
Pass siteUrl — the site’s public origin, normally process.env.NEXT_PUBLIC_SITE_URL — if you want the three tags a page cannot derive from its own content: <link rel="canonical">, og:url, and an og:image resolved to an absolute URL. A relative image path is correct in an <img src> and ignored by every social crawler, so a site that stores its images that way has blank social cards until the origin is known. Unset, the SDK emits none of the three rather than guessing: a wrong canonical is worse than an absent one. Add app/not-found.tsx. The factory calls notFound() for an unknown slug, so a missing page has to answer a real HTTP 404 rather than a 200 with “404” in the body. The factory also handles draft-mode detection, the editor overlay, navigation chrome, and switching between published and draft reads. Do not reimplement that branching. Production shape. The default mode: "auto" decides published versus editor per request, so every page renders dynamically. A production site uses mode: "static" on app/[[...slug]]/page.tsx, a mode: "preview" route at app/preview-draft/[[...slug]]/page.tsx, and a middleware or proxy that rewrites editor requests to the second. Middleware. Next 15 uses middleware.ts with createEditorMiddleware from @avocadostudio-ai/site-sdk/middleware. Next 16 renamed the convention and reads config by static analysis, so it uses proxy.ts with createEditorProxy from @avocadostudio-ai/site-sdk/proxy and a config that is a static object literal — a config destructured from a factory result works on 15 and is rejected on 16. Either file goes at the project root, or in src/ if the app uses one. A preview route you write yourself is gated first. Its first line is const editor = await requireEditorContext(await searchParams) from @avocadostudio-ai/site-sdk/draft, before it reads any CMS draft or orchestrator draft. It answers 404 unless the request is an authorized editor render: draft mode or a valid secret in production. A preview route that read drafts first and asked second has shown unreleased pages to anyone who typed its URL.

3. Block registration — the boundary itself

On an existing site, your own components are the blocks. Register each one with a schema that names its content props and the kind of each field:
This registration is the safety boundary. name, price and blurb are editable; a badgeVariant you did not declare is not reachable by any operation, any prompt, or any model. Declaring less is the conservative choice, and adding a field later is one line. Three declarations narrow it further, and both the panel and the ops engine honour them:
  • fixed: true on a block type hides move, delete and add, and the ops engine refuses them. Use it for a section the template draws in a fixed place.
  • readOnly: true, with a readOnlyReason, on a field the site shows but cannot store — shared asset alt text, a slug, a date. The panel shows it disabled with the reason, and writes are refused.
  • shared: true on a block type makes every instance with the same block id one piece of content — a header or footer injected into every page. An edit on one page reaches every page’s draft.
The twenty built-in block types — Hero, FeatureGrid, Testimonials, FAQAccordion, CTA, Card, CardGrid, RichText, Stats, TwoColumn, Footer, SiteHeader, Embed, Banner, Carousel, Gallery, Tabs, Table, Quote, Video — are a starting catalogue for sites built from scratch. On an existing site they are optional.
Import only from the SDK. registerBlock, z and the block-meta types come from @avocadostudio-ai/site-sdk/blocks; the marker helpers from @avocadostudio-ai/site-sdk/markers; the coverage gate from @avocadostudio-ai/site-sdk/coverage. Never import @avocadostudio-ai/shared, @avocadostudio-ai/blocks, @avocadostudio-ai/preview-adapter or a bare zod — a dependency of a dependency is not a specifier your source may use. Under pnpm it will not resolve; under npm’s flat hoisting it resolves today and breaks the first time something re-hoists, and two copies of zod fail in ways that look like schema bugs.
→ Custom blocks

4. Markers — which element is which field

Declaring a field makes it editable in the property panel. Making it editable on the page — inline text editing, the hover pill, the image Change button — needs the renderer to say which DOM element carries which path, using @avocadostudio-ai/site-sdk/markers:
Paths are scoped from the block down, so a list row drawn by its own component needs a scope on a wrapper:
Two things about this seam are worth stating plainly, because they are what integrations get wrong:
  • A missing scope is wrong, not absent. The child marks a bare question, the overlay resolves it against the enclosing block, and an edit to a headline inside a column patches a prop the section does not have.
  • display: "contents" is the answer to the layout problem. A wrapper added only to carry a scope otherwise becomes the flex or grid item, and the layout the rows had becomes the layout of a column of wrappers. With display: contents the rows go on being their parent’s children. Leave it off when the wrapper is one you were rendering anyway.
This is the longest part of a real integration: it is a pass over every renderer.

5. The content adapter

Two functions. getPages() returns your content as pages of blocks; onPublish(pages, config) writes edits back. How much sits behind them is a property of your CMS, not of Avocado.
  • Files or JSON — nearly nothing. @avocadostudio-ai/site-sdk/publish-handlers/json-file ships a working handler. See examples/sample-site/.
  • A mainstream CMS — working examples ship for Contentful, Sanity and Strapi under examples/contentful-site/, examples/sanity-site/ and examples/strapi-site/. Rich text converts through a shared pivot; the converters for Storyblok, Contentful, Sanity Portable Text and Strapi are re-exported from @avocadostudio-ai/site-sdk/lens, so you need no direct dependency on @avocadostudio-ai/richtext.
  • Field-level localisation, or list rows stored as their own documents — this is where the real work is. Use a field table: one declaration derives the Zod schema, the panel metadata, the projection out of the CMS and the merge back into it, instead of four hand-written things that disagree within a week. Lens packs ship for Storyblok, Sanity and Contentful (@avocadostudio-ai/site-sdk/lens/storyblok, /lens/sanity, /lens/contentful).
For the preview, overlay the draft onto the site’s own data rather than rewriting templates: applyDraft and applyDraftBlocks from @avocadostudio-ai/site-sdk/lens write only the mapped block props into the object your route already fetched, and return it unchanged when there is no draft. Two rules govern every write, and both are load-bearing:
  1. merge takes the live CMS document as its source, never a snapshot Avocado holds. Every field the table never declared survives by construction.
  2. Publishing is a field-level diff, not a snapshot overwrite, so a page nobody touched writes nothing.
→ CMS adapters · Multilingual · Publishing

6. Registration

From the project directory:
The bin script ships with @avocadostudio-ai/site-sdk. It detects the framework, POSTs the site config and the draft secret to POST /sites/register, and — unless the orchestrator reports that the secret does not match its own — writes the env file that framework reads: .env.local with DRAFT_MODE_SECRET, ORCHESTRATOR_URL and the NEXT_PUBLIC_* names on Next, .env with DRAFT_MODE_SECRET, ORCHESTRATOR_URL and AVOCADO_SITE_ID on Astro. The secret comes from --secret, the env file, or is generated. Pass --orchestrator: the default is http://localhost:4200, the standalone server’s address, and a library-mode orchestrator lives inside your own app instead. Nothing has to be started for it — it is up when your dev script is. If the POST cannot connect the command says so and exits 0, having still written the env file with a secret it could not check; the site still loads in the editor, because a library-mode mount reports the one site it is mounted in whether or not anyone registered it. Flags: --id, --port, --orchestrator, --secret, --session, --purpose, --preview-url, --token (access token for an orchestrator with a password; defaults to ORCHESTRATOR_ACCESS_TOKEN), --cwd. Run npx avocado-register --help for the full list. The most common failure is a mismatch between the site’s DRAFT_MODE_SECRET and the editor’s build-time VITE_SITE_DRAFT_SECRET. Against an orchestrator that has one, the script checks for it and stops before writing anything, saying where the right value is: for a standalone orchestrator run from an Avocado checkout, DRAFT_MODE_SECRET in that checkout’s .env. Re-run with --secret <value>.

One more seam, if your layout mounts third-party scripts

The editor renders your real pages in an iframe. A consent banner, analytics, a tag manager or another visual editor’s bridge mounted in the root layout will cover the page being edited and write a pageview for every block someone clicks through. A Next layout receives no searchParams, so the check is header-based:
It answers a rendering question, not an authorization one — what may see unpublished content is decided by resolveEditorContext, which requires draft mode or a valid secret. Never gate content or credentials on isEditorRender.

Doing it in order

1

Read the concepts

Core concepts — pages, blocks, operations, draft mode. The rest of the docs assume this vocabulary.
2

Survey the site

The survey — toolchain, the section map, and the known blockers. Report it before changing anything.
3

Get the editor talking to an orchestrator

the quickstart. Confirm you can open the editor at http://localhost:4100 and see a session before you change anything in your own repo.
4

Mount the two helpers

Seams 1 and 2 — the editor API route and the page factory, from Next.js integration. At the end of this step the site should load inside the editor’s iframe and draft mode should toggle.
5

Declare your blocks

Seam 3 — Custom blocks. Register your own components and the props you are prepared to let marketing change.
6

Mark up the renderers

Seam 4. Work block by block and check coverage after each one rather than at the end; a bad scope is much easier to find in a diff of one component.
7

Adapt the content source

Seam 5 — a field table if there is a CMS behind this, CMS adapters otherwise.
8

Register the site

Seam 6 — npx avocado-register --name "...". The site appears in the editor’s dashboard on the next open or refresh.
9

Prove it with a number

Run editableCoverage and panelCoverage from @avocadostudio-ai/site-sdk/coverage and put them in CI. See below.
10

Pass the QA gate

npx avocado qa, then its manual pass. Not finished until both are.
11

Optional — enable the visual editor

Visual editor, opt-in per site. Do this after the contract above is satisfied, not alongside it.

How you know it is finished

Not “it builds”, and not “the dev server started”. A site can typecheck, build, serve a valid manifest and still be unusable to edit.
  • editableCoverage compares the fields the manifest declares against the data-editable-target markers a rendered page actually carries, and reports marked/expected. A field that lost its marker in a refactor fails this instead of silently losing inline editing.
  • panelCoverage asks whether the property panel is intelligible: rowsLabelled/rowsExamined plus findings for list rows nobody can tell apart, polymorphic branches that never narrow, props in your content described by nothing, and block type names colliding with the built-ins.
Both are pure functions from @avocadostudio-ai/site-sdk/coverage — no browser, no screenshot, no model call — so they belong in CI. The same panel check is available to agents as the avocado-check-editing-surface MCP tool. Target 100% editable coverage and zero panel findings. Where you cannot reach it, write down which field and why. → Coverage checks

The last gate: avocado qa

Coverage proves the markers are there. It cannot see what went wrong on the integrations this gate comes from, all of which passed type-check, build and a curl of the editor API: a live-preview SDK that threw inside the frame, bullets that rendered as paragraphs, a preview route that had drifted from the public one, pages that 404’d after a block schema changed. Those live in a browser, inside the editor frame, with edited content, or across a schema change — so that is where the last check goes.
It runs against the running site and a throwaway orchestrator session — never yours — and writes a JSON report and a one-screen summary. Some checks are not automated yet — a no-op round trip per page, a synthetic edit per field kind, loading old sessions against a new manifest — and the command prints them on every run so a green summary is not read as covering them. See the QA gate. An integration is not finished until it passes. Then take the manual pass it prints — the part no script can judge:
  • Clicking each visible section selects a block with a sensible name and only its fields
  • Fields that cannot be written (asset alt text, slugs, dates) are not offered as editable
  • A chat edit and a panel edit both update the preview within a few seconds
  • Rich text in the panel looks like rich text on the page (lists, headings, links)
  • Asking for a language the site does not have produces a question, not an overwrite
  • Publishing one page changes only that page in the CMS, and the public site shows it after reload

Then send one edit

From the editor’s chat panel, ask for something small — “change the hero headline to ‘Hello world’”. You should see the plan stream into the preview, the page update, and an undo entry appear in the history. If it does not, start at chat troubleshooting.

If you get stuck

There is no penalty for switching paths halfway — the orchestrator only ever sees the result.
  • Hand the remainder to your own coding agent with the skills on that page, which encode everything above.
  • Use the onboarding agent if what you actually need is content bootstrapped from a live URL rather than a codebase wired up.
  • If the contract itself is the problem — a seam that does not fit your site’s shape — get in touch. Several of the helpers on this page exist because an integration hit exactly that and told us.