You are integrating my Next.js project at [PROJECT_PATH] with Avocado Studio, so
that my marketing team can edit this site's content through Avocado's AI chat
editor and visual editor — without any of those edits ever touching code.
Work on a branch. I will review the diff.
## The one rule that shapes everything
Avocado edits CONTENT, never the codebase. Every edit the system can make is one
of 19 typed operations against props I have explicitly declared as content. Your
job is to declare that boundary correctly and mark up the renderers so the editor
can address what is inside it. You are not building an editor; you are declaring
a contract.
A prop I declare is editable. A prop I do not declare is invisible to the editor
forever. When in doubt, declare less and ask me.
## Required reading — read BEFORE writing any code, do not skim
1. https://docs.avocadostudio.dev/concepts
The page model: PageDoc, BlockInstance, operations, draft mode.
2. https://docs.avocadostudio.dev/sites/manual
The integration contract — every seam this work has to satisfy.
3. https://docs.avocadostudio.dev/integration/nextjs-integration
The canonical reference: routes, markers, proxy/middleware, metadata, images.
4. https://docs.avocadostudio.dev/integration/custom-blocks
How my existing React components become editable blocks. This is the main
event for this project — the built-in block catalogue is for sites built from
scratch, and mine already exists.
5. https://docs.avocadostudio.dev/integration/coverage
The two numbers that say the integration is finished. Read this before you
start so you know what you are being measured on.
Read these only if they apply:
- https://docs.avocadostudio.dev/integration/field-table — if my content is in a
CMS. Storyblok and Sanity have ready-made lens packs.
- https://docs.avocadostudio.dev/integration/cms-adapters — if my CMS is not one
of those, or you need the getPages/onPublish shape.
- https://docs.avocadostudio.dev/integration/multilingual — if my site is
multilingual. Read it before writing a single CMS write path; a wrong
projection corrupts content invisibly.
- https://docs.avocadostudio.dev/integration/non-nextjs — only if this is not a
Next.js App Router project. If it is not, STOP and ask me how to proceed.
## What to do, in this order
### 1. Survey before you touch anything
Report back to me, and wait if anything surprises you:
- Next major version, App Router vs Pages, package manager, where routes live.
- Where content comes from today: files, a CMS, MDX, hard-coded JSX.
- The list of section-level React components that render page content — these
are the candidate blocks.
- Whether a root layout mounts analytics, a consent banner, a tag manager, or
another visual editor's bridge. Note them; step 5 deals with them.
### 2. Install and mount the two helpers
Install it with whatever package manager this project already uses — the
lockfile says which. Do not introduce a second one.
```bash
npm install @avocadostudio-ai/site-sdk # or pnpm add / yarn add / bun add
```
Mount the editor API as ONE catch-all route at `app/api/editor/[...path]/route.ts`:
```ts
import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
import { getPages, publishPages } from "@/lib/my-cms"
import { registerBlocks } from "@/avocado/blocks"
export const { GET, POST, OPTIONS } = createEditorApiHandler({
getPages: () => getPages(),
registerBlocks, // see step 3 — pass it, do not rely on import order
blockTypes: ["PricingTier", "LogoWall"], // narrow the manifest to what this site renders
onPublish: async (pages, config) => { await publishPages(pages, config); return { ok: true } },
publishSecret: process.env.PUBLISH_TOKEN,
})
```
That one file serves all five endpoints: `/api/editor/blocks`,
`/api/editor/pages`, `/api/editor/draft`, `/api/editor/draft/disable`,
`/api/editor/publish`. It validates `?secret=` against `DRAFT_MODE_SECRET`,
refuses non-internal redirects, sets the draft cookie and answers CORS
preflight. Do NOT hand-write any of those routes — they are security-critical.
`PUBLISH_TOKEN` is not optional anywhere this site runs with
`NODE_ENV=production`: with no `publishSecret` configured, `/api/editor/publish`
answers 401 and names the variable, because an endpoint that overwrites the
site's content must not be open. Generate a value into `.env.local`, and set the
same value on the orchestrator — it sends it as the `x-publish-token` header.
On my machine the route stays open and warns once.
The same route also refuses a publish that would remove every page: 409, unless
the body says `"allowDelete": true`. Do not "fix" that by passing `allowDelete`
from the client. Pass `maxPagesRemoved` if this site wants a tighter bound.
Then the page factory at `app/[[...slug]]/page.tsx`:
```tsx
import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
import { getPage, getSlugs, getSiteConfig } from "@/lib/my-cms"
const { Page, generateStaticParams, generateMetadata } = createSitePage({
siteId: "[SITE_ID]",
siteName: "[SITE NAME]",
siteUrl: process.env.NEXT_PUBLIC_SITE_URL, // canonical, og:url, absolute og:image
getPage, getSlugs, getSiteConfig,
})
export default Page
export { generateStaticParams, generateMetadata }
```
Export all three. `generateMetadata` is what gives each page its own `<title>`,
description and Open Graph tags; without it every page inherits the root
layout's. Add an `app/not-found.tsx` too — the factory calls `notFound()` for an
unknown slug, so a missing page must answer a real 404 and not a 200 with "404"
in the body.
`siteUrl` is the site's public origin, and it is what turns on the three tags no
page can derive from its own content: the canonical link, `og:url`, and an
`og:image` resolved to an absolute URL. If my content stores image paths like
`/images/hero.webp`, every social card is blank without it — that path is correct
in an `<img src>` and no crawler resolves it. Read it from the environment so a
preview deployment does not claim to be production; unset, the SDK emits none of
the three rather than guessing.
Then DELETE or move every route file whose content now comes from `getPage` —
`app/page.tsx`, `app/about/page.tsx`, and so on. A route more specific than the
catch-all keeps winning, Next reports no conflict and logs nothing, so those URLs
go on serving the old component and the integration looks dead while being
perfectly wired. List for me every route you removed and every one you left, and
say why you left it.
Next 15 uses `src/middleware.ts` with `createEditorMiddleware` from
`@avocadostudio-ai/site-sdk/middleware`. Next 16 uses `src/proxy.ts` with
`createEditorProxy` from `@avocadostudio-ai/site-sdk/proxy`, and `config` must be
a static object literal.
### 3. Declare MY components as the blocks
My own React components are the blocks. Register each with a schema saying which
props are content and what kind each field is:
```ts
// avocado/blocks.ts
import { registerBlock, z } from "@avocadostudio-ai/site-sdk/blocks"
export function registerBlocks() {
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" } },
},
})
}
```
Rules:
- Import `registerBlock` and `z` ONLY from `@avocadostudio-ai/site-sdk/blocks`.
Never from `@avocadostudio-ai/shared`, `@avocadostudio-ai/blocks` or a bare
`zod` — a dependency of a dependency is not a specifier my source may use, and
two copies of zod fail in ways that look like schema bugs.
- Pass `registerBlocks` to `createEditorApiHandler` rather than relying on
import side effects. Next's dev bundler does not honour source-order side
effects across RSC / SSR / route layers, and a late canonical registration can
silently clobber mine.
- Declare presentation props (variants, spacing, feature flags) NOWHERE. If it
is not content, leaving it out is the whole point.
- Do not *invent* a new block under a name Avocado already uses — `Hero`, `CTA`,
`FeatureGrid`, `Testimonials`, `Footer` and the rest of the catalogue. My
component is not that block, and the collision is reported as a panel finding
at the end. Register it under a prefixed type and keep the component name as
it is.
**Unless my stored content already uses that name**, which on an existing site
it usually does. Renaming the type means rewriting every page, so register
over it instead: `registerBlock("Hero", …)` deliberately replaces the built-in
definition — its schema *and* its built-in flag — with mine. Then pass
`blockTypes` naming only the types this site renders, so the manifest stops
advertising the twenty built-ins I did not implement. Tell me which built-in
names you replaced.
- If a field is a list of rows, declare it under `meta.listFields` with an
`itemFields` map naming the row shape — not under `meta.fields`, which is for
scalar props only. The property panel needs the row shape to tell rows apart.
- If a component renders a prop with `dangerouslySetInnerHTML`, its stored value
is a STRING OF MARKUP and the kind is `html`, not `richtext`. `richtext` means
a document: declare it and the panel renders the markup literally, as visible
`<span class="…">` text that nobody can edit without breaking, and writes
whatever they type over it. `html` round-trips through the same editor and
preserves the elements and attributes it cannot model.
- A bullet list of plain strings is `stringList`; an array of images is
`imageList`. Both have their own controls. Declaring either as `text` reaches
the panel as nothing at all.
### 4. Mark up the renderers so the editor can address fields
Use `@avocadostudio-ai/site-sdk/markers`:
```tsx
import { editableProps, editableScopeProps } from "@avocadostudio-ai/site-sdk/markers"
<h2 {...editableProps("headline", { kind: "text" })}>{props.headline}</h2>
```
The field path is scoped from the block down — `items[3].question`. That holds
while one component draws the whole block, and STOPS holding the moment a list
row is its own component: the child knows it has a `question` and cannot know it
is `items[3]`. Wrap the row and give the wrapper the scope:
```tsx
{props.items.map((item, i) => (
<div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
<FaqRow item={item} />
</div>
))}
```
Pass `{ display: "contents" }` whenever the wrapper exists only to carry the
scope — otherwise the wrapper becomes the flex or grid item and the layout the
rows had silently becomes the layout of a column of wrappers.
Forgetting a scope is silent and WRONG, not silent and 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. Be
systematic. Every renderer, every field.
### 5. Keep third-party scripts out of the preview
The editor renders my real pages in an iframe. Consent banners, analytics and tag
managers mounted in the root layout will otherwise cover the page being edited
and write a pageview for every block someone clicks.
A layout gets no `searchParams`, so use the header-based check:
```tsx
import { isEditorRender } from "@avocadostudio-ai/site-sdk/draft"
export default async function RootLayout({ children }) {
const inEditor = await isEditorRender()
return (
<html><body>
{children}
{!inEditor && <CookieConsent />}
{!inEditor && <Analytics />}
</body></html>
)
}
```
It answers a rendering question, not an authorization one. Never gate content or
credentials on it — `resolveEditorContext` is what decides who may see
unpublished content.
### 6. If my content is in a CMS, use a field table
Do not hand-write four things that have to agree. Declare one table and derive
the Zod schema, the panel metadata, the projection out of the CMS and the merge
back into it:
```ts
import { createLens, registerFieldTable } from "@avocadostudio-ai/site-sdk/lens"
import { storyblokPrimitives, storyblokLocale } from "@avocadostudio-ai/site-sdk/lens/storyblok"
const primitives = storyblokPrimitives()
registerFieldTable(TABLE, { primitives })
export const lens = createLens({
table: TABLE,
locale: storyblokLocale("de", ["de", "en", "fr"]),
primitives,
})
```
Sanity is the same two calls with `sanityPrimitives()` and `sanityLocale(...)`
from `@avocadostudio-ai/site-sdk/lens/sanity`. For any other CMS, read the field
table page and write the primitives pack — do not skip the table and hand-roll
the projection.
`merge` takes the LIVE CMS document as its source, never a snapshot Avocado
holds, so every field the table never declared survives by construction. Preserve
that property. If you find yourself writing a whole document from Avocado props,
stop and ask me.
If the site's CMS client is a visual-editing / draft client, check whether it
stega-encodes strings. Those invisible characters must be stripped before props
reach the adapter, or publishing writes them back into the CMS.
### 7. Register the site with the orchestrator
With my dev server running, from my project directory:
```bash
# library mode — the orchestrator is inside my own app
npx avocado-register --name "[SITE NAME]" --orchestrator http://localhost:3000/api/avocado
```
**Pass `--orchestrator`.** The default is `http://localhost:4200`, which is the
standalone server and not what this project runs. Omitting it against a
library-mode site is the single most common way this step goes wrong: it either
cannot connect, or it registers with whatever else is on that port.
The command does two separable things. It generates a `DRAFT_MODE_SECRET` into
`.env.local` if there is not one and fills in `NEXT_PUBLIC_DEFAULT_SITE_ID`,
`NEXT_PUBLIC_SITE_NAME` and `NEXT_PUBLIC_EDITOR_ORIGIN` — that half is local and
always runs. Then it POSTs the site config to `/sites/register`, which is what
carries the name, preview URL and purpose across, and writes `ORCHESTRATOR_URL`
once that POST has been answered. Other flags: `--id`, `--port`, `--secret`,
`--session`, `--purpose`, `--preview-url`, `--token`.
If it reports that it could not reach an orchestrator, that is a report and not
a failure — it exits 0, and the `.env.local` half has already happened. A
library-mode mount already serves the one site it is mounted in, so the site
appears in the editor regardless. Re-run the command with the right
`--orchestrator` to finish the registry entry. Either way, do not register by
hand-editing `.env.local`.
## What NOT to do
- Do not invent endpoint paths. All five live under `/api/editor/*`, exactly as
listed in step 2.
- Do not hand-write the draft routes. Use `createEditorApiHandler`.
- Do not import from `@avocadostudio-ai/shared`, `@avocadostudio-ai/blocks`,
`@avocadostudio-ai/preview-adapter` or a bare `zod`. Everything an integration
needs is re-exported by the SDK.
- Do not change my published-data path. Draft mode is additive.
- Do not refactor my components beyond adding markers and, where a list row
needs one, a wrapper.
- Do not commit `DRAFT_MODE_SECRET`. It lives in `.env.local`.
- Do not touch Puck / visual-editor wiring in this pass. It is a separate opt-in.
- Do not declare a prop as content because it looked convenient. Ask me.
## Verification — finish on a NUMBER, not on "it builds"
A site can typecheck, build, serve a valid manifest and still be unusable to
edit. Two coverage checks say whether it is actually done. Add a script that
runs both and report the figures:
```ts
// scripts/avocado-coverage.ts
import {
extractMarkedBlocks, editableCoverage, formatEditableCoverage,
panelCoverage, formatPanelCoverage,
} from "@avocadostudio-ai/site-sdk/coverage"
const manifest = await (await fetch("http://localhost:3000/api/editor/blocks")).json()
const { pages } = await (await fetch("http://localhost:3000/api/editor/pages")).json()
for (const page of pages) {
// page.slug already starts with "/" — string-joining it onto the origin is
// how this ends up fetching "http://localhost:3000//" and measuring nothing.
const html = await (await fetch(new URL(page.slug, "http://localhost:3000"))).text()
console.log(page.slug, formatEditableCoverage(editableCoverage(manifest, extractMarkedBlocks(html))))
}
console.log(formatPanelCoverage(panelCoverage(manifest, pages)))
```
Run it against `next dev`. `editableCoverage` compares the manifest against the
markers a preview draws, and markers are emitted only on an editor render —
pointed at a production build it finds no blocks and reports zero, which reads
exactly like an integration that marks nothing.
`editableCoverage` reports `marked/expected` — of the fields the manifest
declares, how many the rendered page actually carries a marker for. `panelCoverage`
reports `rowsLabelled/rowsExamined` plus findings: list rows nobody can tell
apart, polymorphic branches that never narrow, props in my content that nothing
describes, and block type names colliding with Avocado's built-ins.
**Target: 100% editable coverage on every page, and zero panel findings.** If you
cannot reach it, do not quietly stop — list every remaining gap with the block
type, the field path and why.
Also confirm:
- [ ] My project's typecheck and build pass.
- [ ] Every existing page still renders at its existing route with draft mode OFF.
- [ ] Every page you migrated is being served by the catch-all and not by a
leftover route file: its HTML under `next dev` carries `data-block-id`.
"The URL still works" does not prove this — a shadowing route makes it work
by serving the old component.
- [ ] `curl http://localhost:3000/api/editor/blocks` returns a non-empty `blocks` array.
- [ ] `curl -i 'http://localhost:3000/api/editor/draft?secret=<secret>&redirect=/'` is a 307 and sets the draft cookie.
- [ ] `curl -i 'http://localhost:3000/api/editor/draft?secret=wrong&redirect=/'` is rejected.
- [ ] `curl -i 'http://localhost:3000/api/editor/draft?secret=<secret>&redirect=https://evil.example'` does NOT redirect off-site.
- [ ] An unknown slug answers HTTP 404, not 200.
- [ ] Each page has its own `<title>` and description in the built HTML.
- [ ] With `NEXT_PUBLIC_SITE_URL` set, a page that declares an image emits an
absolute `og:image` and a canonical link.
- [ ] `curl -i -X POST http://localhost:3000/api/editor/publish -H 'content-type:
application/json' -H 'x-publish-token: <PUBLISH_TOKEN>' -d '{"pages":[]}'`
answers 409 and leaves the content store untouched.
- [ ] No `DRAFT_MODE_SECRET` value appears in any committed file.
- [ ] `npx avocado-register` printed `Registered "<name>" with the orchestrator`
and no warnings. If it printed `The site is NOT registered` instead, say
so and say which URL it tried — do not report the step as done.
## Report back
1. Files created or modified, one sentence each.
2. The block table: every type you registered, its content fields, and the
props you deliberately left undeclared.
3. `editableCoverage` per page and `panelCoverage` overall, as numbers.
4. Every remaining gap, with block type and field path.
5. Anything you had to guess about my codebase.