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

# The integration contract

> Every seam an Avocado Studio integration has to satisfy — the editor API, the page factory, block registration, markers, the CMS adapter, and the coverage check that says it is finished. Whether a human or an agent writes the code, this is what it has to meet.

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.

<Note>
  **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](/sites/coding-agent) — and use this page to review what comes back. The contract is the same either way.
</Note>

## 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`](/integration/astro-integration) 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](/integration/non-nextjs) — 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.**

| Look for | What it does inside the editor | What to do |
| - | - | - |
| `X-Frame-Options`, or a CSP `frame-ancestors` without the editor's origin | The editor frame stays blank | `withAvocado` writes framing headers for editor requests from `EDITOR_CORS_ORIGINS` / `NEXT_PUBLIC_EDITOR_ORIGIN`. If the site sets its own, add the editor origins to them, or pass `withAvocado(config, { framing: false })` and write both rules yourself — see [Framing](/integration/nextjs-integration#framing) |
| A CMS live-preview SDK (`ContentfulLivePreviewProvider`, Sanity's or Storyblok's bridge) | Throws when framed by anything but the CMS's own app | Pass `targetOrigin={[editorOrigin]}`, or skip the provider on editor renders — see [Draft Mode](/integration/nextjs-integration#sites-that-already-use-draft-mode) |
| Existing `draftMode()` use | Both previews share Next's `__prerender_bypass` cookie, and the CMS's preview requests are rewritten into Avocado's | `createEditorProxy({ draftCookie: false })` or `draftCookie: "editor_draft_session"` |
| A CSS reset such as Tailwind's preflight | Bulleted lists render as plain paragraphs, headings as body text — invisible until real content has one | A `.rich-text` class on rich-text containers that restores list markers, heading sizes, link underlines and quotes |
| Image components written for the CMS alone | An image chosen in the editor (relative, or another host) crashes a bare `new URL(url)` or a CDN-only blur placeholder | Parse defensively and fall back to a plain image — see [Images](/integration/nextjs-integration#images) |
| A CMS space without the content model the site queries | Nothing renders until the model and seed exist | Build and seed it first; render every page afterwards, not only the home page |
| More than one locale in the CMS | Round trips that fabricate translations | [Multilingual content](/integration/multilingual), before designing anything |

**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

```mermaid theme={null}
flowchart TB
    subgraph Repo["<b>Your repository</b>"]
        API["<b>1. Editor API</b><br/>one catch-all route under<br/>app/api/editor"]
        PAGE["<b>2. Page factory</b><br/>your catch-all<br/>page route"]
        BLOCKS["<b>3. Block registration</b><br/>which props are content"]
        MARK["<b>4. Markers</b><br/>which element is which field"]
        CMS["<b>5. Content adapter</b><br/>getPages / onPublish"]
    end
    REG["<b>6. Registration</b><br/>npx avocado-register"]
    ORCH["Orchestrator"]

    BLOCKS --> API
    CMS --> API
    CMS --> PAGE
    MARK --> PAGE
    API <--> ORCH
    PAGE <--> ORCH
    REG --> ORCH
```

### 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:

| Endpoint | Method | Contract |
| - | - | - |
| `/api/editor/blocks` | GET | The block manifest — what block types exist and what fields each has |
| `/api/editor/pages` | GET | `{ pages: PageDoc[] }`, plus `siteConfig` when you supply `getSiteConfig` |
| `/api/editor/draft` | GET | Validates `?secret=` against `DRAFT_MODE_SECRET`, sets the draft cookie, redirects — internal targets only. Answers 401 for a wrong secret and 503 `DRAFT_MODE_SECRET is not set` when the site has none |
| `/api/editor/draft/disable` | GET | Clears draft mode |
| `/api/editor/publish` | POST | Receives edited pages back; checked against `publishSecret` via the `x-publish-token` header. Refuses with 401 under `NODE_ENV=production` when no `publishSecret` is configured, and with 409 when the publish would remove every page |

<Warning>
  **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.
</Warning>

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](/integration/nextjs-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:

```ts theme={null}
import { registerBlock, z } from "@avocadostudio-ai/site-sdk/blocks"

registerBlock("PricingTier", {
  schema: z.object({ name: z.string(), price: z.string(), blurb: z.string() }),
  meta: {
    displayName: "Pricing Tier",
    fields: { blurb: { kind: "richtext" }, price: { kind: "text" } },
  },
})
```

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.

<Warning>
  **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.
</Warning>

→ [Custom blocks](/integration/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`:

```tsx theme={null}
<h2 {...editableProps("headline", { kind: "text" })}>{props.headline}</h2>
```

Paths are scoped from the block down, so a list row drawn by its own component needs a scope on a wrapper:

```tsx theme={null}
{props.items.map((item, i) => (
  <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
    <FaqRow item={item} />
  </div>
))}
```

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](/integration/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](/integration/cms-adapters) · [Multilingual](/integration/multilingual) · [Publishing](/integration/publishing)

### 6. Registration

From the project directory:

```bash theme={null}
npx avocado-register --name "My Site" --orchestrator http://localhost:3000/api/avocado
```

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:

```tsx theme={null}
import { isEditorRender } from "@avocadostudio-ai/site-sdk/draft"

const inEditor = await isEditorRender()
// {!inEditor && <CookieConsent />}
```

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

<Steps>
  <Step title="Read the concepts">
    **[Core concepts](/concepts)** — pages, blocks, operations, draft mode. The rest of the docs assume this vocabulary.
  </Step>

  <Step title="Survey the site">
    **[The survey](#before-you-write-anything-the-survey)** — toolchain, the section map, and the known blockers. Report it before changing anything.
  </Step>

  <Step title="Get the editor talking to an orchestrator">
    **[the quickstart](/quickstart#start-both-processes)**. Confirm you can open the editor at `http://localhost:4100` and see a session before you change anything in your own repo.
  </Step>

  <Step title="Mount the two helpers">
    Seams 1 and 2 — the editor API route and the page factory, from **[Next.js integration](/integration/nextjs-integration)**. At the end of this step the site should load inside the editor's iframe and draft mode should toggle.
  </Step>

  <Step title="Declare your blocks">
    Seam 3 — **[Custom blocks](/integration/custom-blocks)**. Register your own components and the props you are prepared to let marketing change.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Adapt the content source">
    Seam 5 — a field table if there is a CMS behind this, **[CMS adapters](/integration/cms-adapters)** otherwise.
  </Step>

  <Step title="Register the site">
    Seam 6 — `npx avocado-register --name "..."`. The site appears in the editor's dashboard on the next open or refresh.
  </Step>

  <Step title="Prove it with a number">
    Run `editableCoverage` and `panelCoverage` from `@avocadostudio-ai/site-sdk/coverage` and put them in CI. See below.
  </Step>

  <Step title="Pass the QA gate">
    **[`npx avocado qa`](#the-last-gate-avocado-qa)**, then its manual pass. Not finished until both are.
  </Step>

  <Step title="Optional — enable the visual editor">
    **[Visual editor](/integration/puck-mode)**, opt-in per site. Do this after the contract above is satisfied, not alongside it.
  </Step>
</Steps>

## 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](/integration/mcp-server).

Target 100% editable coverage and zero panel findings. Where you cannot reach it, write down which field and why.

→ [Coverage checks](/integration/coverage)

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

```bash theme={null}
npx avocado qa
```

It runs against the running site and a throwaway orchestrator session — never
yours — and writes a JSON report and a one-screen summary.

```mermaid theme={null}
flowchart LR
    P["<b>0. Preflight</b><br/>versions, disk,<br/>frame headers,<br/>known blockers"] --> S["<b>1. Static</b><br/>foreign peers,<br/>public JS budget,<br/>build with --build"]
    S --> C["<b>2. Content contract</b><br/>manifest vs pages,<br/>panel coverage,<br/>manifest.lock"]
    C --> R["<b>3. Editor render</b><br/>every page in a frame,<br/>coverage, rich text,<br/>images, draft probe"]
    R --> I["<b>Invasiveness</b><br/>what the<br/>integration changed"]
    I --> M["<b>5. Manual pass</b><br/>ten minutes<br/>in the Studio"]
```

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](/integration/qa).

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](/observability/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](/sites/coding-agent)** with the skills on that page, which encode everything above.
* Use the **[onboarding agent](/sites/site-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.


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