/preview route required.
Related:
- Environment reference — every variable, grouped by what it configures
- Custom Blocks — register your own component types alongside (or instead of) the built-in blocks
- Architecture — how the three services communicate
How it works in two helpers
The SDK collapses the entire integration into two factory functions. Most adopters need exactly two new files; nothing else changes in your project.
You wire both with your existing CMS / content fetchers (
getPage, getSlugs, getSiteConfig), then register the site with npx avocado-register. That’s the whole integration.
If you need fine-grained control instead, the low-level primitives section shows the underlying handlers (createBlocksHandler, createDraftEnableHandler, createDraftDisableHandler, fetchEditorPage, fetchEditorSlugs).
Walkthrough
1
Install the SDK
From your Next.js project root:Peer dependencies (It is an optional peer, which means no package manager installs it for you, and
next ≥ 15, react ≥ 19, react-dom ≥ 19) should already be in your project.For library mode — the orchestrator running inside your own Next app — add one more:@avocadostudio-ai/site-sdk/server — where createOrchestrator lives — is a hard import of it.Do not add better-sqlite3 yourself. orchestrator-core depends on it
at ^12.9.0, so the line above already brings it; naming it again is how a
project ends up with two copies of a native module, loaded from whichever
one resolves first. That is not hypothetical — this page named it, unranged,
for one release, and the clean-room integration that followed installed it
twice at two different majors.2
Mount the catch-all editor API route
Create This single file exposes:
app/api/editor/[...path]/route.ts:GET /api/editor/blocks— block manifest (auto-built from the SDK’s built-in registry, override viagetManifestfor custom blocks)GET /api/editor/pages—{ pages: PageDoc[] }for editor session bootstrapGET /api/editor/draft?secret=...&redirect=...— Draft Mode entry, validatessecretagainstDRAFT_MODE_SECRET, only allows internal redirectsGET /api/editor/draft/disable?redirect=...— Draft Mode exitPOST /api/editor/publish— receives published pages back from the editor
onPublish ever
runs: one with no publishSecret configured, which is 401 under
NODE_ENV=production, and one that would leave the site with no pages at
all, which is 409 unless the body carries allowDelete: true.
maxPagesRemoved tightens the second rule from “not all of them” to a
number you choose, counted against what getPages returns.
Publishing is where both rules are written down,
including what the refusals say and how to get past them on purpose.3
Replace your page route with createSitePage
Create (or replace)
app/[[...slug]]/page.tsx:createSitePage handles, in order:- Detecting Draft Mode via
next/headersand switching reads tofetchEditorPage/fetchEditorSlugs(which call the orchestrator) - Building site nav/header chrome from
getSiteConfig - Rendering blocks via the SDK’s
renderBlocksand the shared block library - Mounting the live
EditorOverlaywhen in editor mode - Falling back to your CMS data if the orchestrator is unreachable
- Deriving
<title>,<meta name="description">, and Open Graph tags from the page (see Metadata and SEO) - Calling
notFound()when no page exists for the slug, so an unknown URL answers a real 404 rather than a 200 with a “404” body
notFound(), add an app/not-found.tsx if you
want your own chrome around the 404. Without one, Next renders its default
not-found page — correct status, no styling.Your existing lib/my-cms.ts does not change — createSitePage calls into it.4
Register the site with the orchestrator
In library mode there is no orchestrator to start — it is mounted inside your own app, so your Omit
dev script running is the whole prerequisite. Otherwise point at your standalone or hosted instance. From your Next.js project directory:--orchestrator only if you are running the standalone server — the flag defaults to http://localhost:4200, which is that server’s address and wrong for library mode.The CLI (shipped inside @avocadostudio-ai/site-sdk) will:- Generate a
DRAFT_MODE_SECRETif.env.localdoesn’t already have one (32 random bytes, hex-encoded). - Write
DRAFT_MODE_SECRET,NEXT_PUBLIC_DEFAULT_SITE_ID,NEXT_PUBLIC_SITE_NAME,NEXT_PUBLIC_EDITOR_ORIGINto.env.localif missing (existing values are never overwritten). - POST your site config to
${ORCHESTRATOR_URL}/sites/register. - Write
ORCHESTRATOR_URLtoo, once that POST has been answered — an address nothing replied at is a guess, and the next run reads this file before falling back to:4200.
npx avocado-register --help. Common ones: --id, --port, --orchestrator, --secret, --session, --purpose.After it succeeds, the site appears in the editor’s dashboard the next time you open or refresh http://localhost:4100.5
Verify the contract
Start your dev server, then run these from a second terminal. All four should pass:And one negative check that’s worth running by hand because it’s the security-critical one:If all five behave as shown, the integration is complete.
6
Open the editor and confirm round-trip
Open
http://localhost:4100. Your site should be in the dashboard. Click its tile, then send a simple edit from the chat panel like “change the hero headline to Hello world”. You should see:- The AI generate an operation
- The preview update inside the iframe
- An undo entry appear in the history
TypeScript types
The SDK re-exports the core types from@avocadostudio-ai/shared. Import what your fetchers need:
PageDoc has shape { id: string; slug: string; title: string; updatedAt: string; meta?: PageMeta; blocks: BlockInstance[] } — title and updatedAt are required. BlockInstance is { id: string; type: string; props: Record<string, unknown> }. See packages/shared/src/schemas.ts in the repo for the Zod schemas that back these types.
Block manifest
The manifest is what tells the editor which block types exist and what props each one accepts.createEditorApiHandler builds it automatically from the SDK’s built-in block registry — you only need to think about it if you have custom React components.
Example response shape from GET /api/editor/blocks:
getManifest function to createEditorApiHandler and the SDK uses yours instead of the built-in one.
Component matching
The editor never infers components from DOM class names. It matches by stabletype strings that must agree across three places:
Existing sites keep their own components
The Quick Start is the greenfield path, and it is worth saying so out loud because nothing on that page does. Two of its steps quietly hand rendering to Avocado:createSitePage and do not import the blocks
stylesheet. Render the draft yourself:
data-block-id and data-block-type; getPreviewWrapperProps
returns both plus the editor-selectable class. Selection is built entirely on
these: a click resolves through closest("[data-block-id]"), and no match is
read as “clicked outside any block” — so a page without them clears the
selection on every click, with selection mode on or off, while framing,
rendering and scrolling correctly the whole time. That case is unambiguous
enough that the overlay says so in the preview itself, in development.
The block id and type must be the same pair your getPage() returns, since that
is what the editor sends back in an operation. The attributes are inert when the
editor is absent.
That is the whole of the required markup. With it the editor frames your
site, clicking a block selects it, the property panel edits every declared
field, and chat edits apply and publish.
Marking the element that draws each individual field is a separate, optional
step — it turns on inline text editing, the hover pills and the image
Change / Remove buttons, none of which anything else depends on. It is
per-component work, so do it when the integration above is running rather than
alongside it: Make the page directly editable.
Live updates: two paths, and which one you get
While the chat is streaming an edit, the preview updates before any reload. There are two mechanisms and you do not choose between them explicitly — the bridge picks based on whether a provider is mounted. The overlay path (default). The bridge writes streamed field values into the DOM directly. It needs nothing from you beyond thedata-editable-target
attributes, and it works with any components at all. This is what an existing
site gets.
The React path (opt-in). Mount LivePreviewProvider from
@avocadostudio-ai/site-sdk/editor with the draft page, and read the effective
blocks with useLivePreviewBlocks() in a client component:
innerHTML write to the same node fight, and React wins in a way that
truncates text.
Rendering modes
createSitePage takes a mode:
The production shape is two routes plus a rewrite: a
"static" route at
app/[[...slug]]/page.tsx, a "preview" route at
app/preview-draft/[[...slug]]/page.tsx with export const dynamic = "force-dynamic",
and createEditorProxy() / createEditorMiddleware() sending editor requests to
the second. npm create avocado-site@latest generates exactly that — run it inside your project and choose “Wire Avocado into this project”.
Keep third-party scripts out of the preview
The preview renders your real layout, which means it renders your consent banner, your tag manager and your analytics. Measured on one integration, per preview render inside the editor iframe: three uncaught errors from a consent platform reaching forparent.location across origins, a cookie banner sitting
over the page being edited, and a page_view written into the site’s own
reporting for every block someone clicked through — twenty blocks, twenty
pageviews, attributed to whoever was editing.
resolveEditorContext() tells a page it is being previewed, and a layout gets
no searchParams, so use isEditorRender() — it reads a header the editor
proxy stamps on the rewritten request:
Metadata and SEO
createSitePage returns a generateMetadata alongside the page. Export it from
your route file and each page gets its own <title>, description, and social
card, derived in this order:
Preview and draft-mode responses are returned
noindex, nofollow — a preview
URL that reaches a crawler publishes work in progress.
Tell the SDK where the site lives
Three of those tags cannot be derived from a page, because none of them is knowable without knowing where the site is served from:<link rel="canonical">,
og:url, and an og:image a crawler can actually fetch. Pass siteUrl and all
three appear. Leave it out and the canonical link and og:url are omitted
outright rather than guessed at — a wrong canonical is worse than an absent one
— while a relative ogImage still goes out, still relative, which is the
failure the next-but-one paragraph is about.
https://example.com/ and not https://example.com//.
The og:image half is the one that fails silently. Content stores image paths
the way the page renders them, and /generated-images/hero.webp is correct in an
<img src> and useless in an og:image — the social crawlers decline to resolve
a relative path against the page, so the tag is present and the card is blank.
With siteUrl set, a relative path is resolved against it; an absolute URL, a
protocol-relative one and a data URI are passed through untouched.
To add anything the SDK still cannot know — a title template, a per-section
override — pass a metadata function. It receives what the SDK derived and the
page it derived it from (null when the slug has no page), and returns what to
emit:
@avocadostudio-ai/site-sdk/seo
(buildPageMetadata, derivePageDescription, derivePageTitle) if you write
your own route instead of using the factory — buildPageMetadata takes the page’s
own absolute URL as canonical and the site’s origin as baseUrl, which is the
split createSitePage makes for you. renderPageMetadata turns the result into
head tags for a host that writes its own <head>.
Images
Avocado writes image URLs your site never chose — Unsplash for stock search, an image model’s blob storage for generated images, the orchestrator’s own origin for uploads.next/image rejects any host missing from images.remotePatterns,
which shows up as a 500 on the first generated image and nothing before that.
withAvocado merges those hosts in for you, so your config only has to name the
hosts you are responsible for — your CMS’s CDN, for example:
ORCHESTRATOR_URL (or NEXT_PUBLIC_ORCHESTRATOR_URL) to allow the
orchestrator’s origin, and assumes http://localhost:4200 outside production.
Pass withAvocado(nextConfig, { images: false }) — it is an option on the second
argument, not a key of your Next config — to manage the list yourself.
From a CommonJS next.config.js
next-config.mjs is ESM-only, so a next.config.js — which is what every Next
project older than about a year has — cannot require() it. Next accepting an
async function as the config export is what makes this work:
next.config.mjs and using a plain import is the other
answer, and the better one if nothing else in your build reaches into the
config with require.
Placeholder URLs must name a raster format —
https://placehold.co/768x512.png?text=Hero.
Without the extension the host returns SVG, and next/image refuses SVG unless
you enable dangerouslyAllowSVG.Server externals
This one only applies to library mode, and it is the config nobody guesses.@avocadostudio-ai/orchestrator-core reaches two kinds of dependency a bundler
must not touch:
- Native binaries —
better-sqlite3for session state,sharpfor image processing. Bundling one produces a build that succeeds and a server that dies loading the.nodefile, on the first request rather than at build time. - Provider SDKs — the Anthropic, OpenAI, Google and MCP clients, several
reached through
await import(...)so that a site which does not use a provider need not install it. Turbopack resolves dynamic imports statically and fails the build withModule not foundover exactly the package you deliberately left out. “An optional peer is genuinely skipped” holds at install time; it does not hold at bundle time.
withAvocado handles both, and there is nothing to add to your config:
AVOCADO_SERVER_EXTERNALS if you need to reference it.
Naming a package you have not installed is a no-op, which is why one list is
correct for every site.
Trailing slashes
If yournext.config sets trailingSlash: true, the editor cannot reach your
site at all until the redirect is turned off — and nothing tells you that is what
went wrong.
Next applies its trailing-slash redirect to /api/* as well:
fetch follows a 308. A CORS preflight does not — a browser treats a
redirect on OPTIONS as a network failure — and the editor calls these routes
from its own origin. So every editor API call fails before the real request is
sent, and the browser reports a generic CORS error that names nothing. Middleware
cannot repair it either: Next’s trailing-slash redirect runs before middleware.
The fix has two halves, and you need both.
withAvocado supplies the first, as soon as it sees trailingSlash: true:
next.config.ts
/about to /about/. The SDK’s
proxy puts the redirect back for page routes, and you have to turn it on:
createEditorMiddleware takes the same options as createEditorProxy —
trailingSlash, draftCookie, previewRoute, editorParam — and produces
the same behaviour under the Next 15 export names. If your site already has a
middleware of its own, see Sites that already have a
middleware instead; you cannot have two.
Only page routes are affected. The proxy’s matcher already excludes /api,
_next and anything with a file extension — which is exactly the set that should
never have carried a trailing slash.
withAvocado(config, { trailingSlash: false }).
Sites that already use Draft Mode
The editor proxy rewrites to the preview route when it sees__editor=1 or
Next’s __prerender_bypass cookie. The cookie is what keeps a click inside the
editor iframe in draft mode, since a link carries no query parameter.
But that cookie belongs to Next, not to Avocado. Anything else that calls
draftMode().enable() sets the same one — Sanity’s Presentation tool and
Contentful’s live preview both do — so on a site that had Draft Mode before it
had Avocado, every one of their preview requests is rewritten into Avocado’s
preview route.
Pass draftCookie: false and the rewrite keys on __editor=1 alone:
proxy.ts
buildEditorQuerySuffix from
@avocadostudio-ai/site-sdk/editor exists for that: append it to hrefs you render
and editor mode survives a click.
Sites that already have a middleware
Next allows one middleware file. A site that already has one — for a CMS’s own visual editor, for locale routing, for auth — cannot also exportcreateEditorProxy, so it composes. Both halves are exported for that:
middleware.ts
buildEditorMatcher() gives you the one the factory would have used if you
want to merge it with your own.
What this costs
On a site whose middleware previously ran only for requests that announced themselves — ahas: [{ type: "query", key: "_storyblok" }] matcher, say —
adding the trailing-slash half changes its character: it now runs on every
page request, because skipTrailingSlashRedirect turned Next’s own redirect
off globally and there is no narrower matcher that still canonicalises
/about to /about/.
One middleware invocation per page request is the price. The alternative is
keeping Next’s redirect and losing the editor, which cannot reach any route on
the origin through a 308 on its preflight. If your site does not set
trailingSlash: true, none of this applies and the editor rewrite can keep
whatever narrow matcher you already had.
Next.js 16
Next 16 renamed themiddleware file convention to proxy, and reads the
config export by static analysis — so it rejects a config that comes
from a function call, including the destructured form that works on Next 15.
create-avocado-site scaffolder detects the target project’s Next major and
writes the right one. Everything else in the SDK is unchanged across 15 and 16.
Environment variables
The values written bynpx avocado-register into your project’s .env.local:
Two more that
avocado-register does not write, because neither has a value it
could guess — set them yourself, per deployment:
And in the editor itself (
apps/editor/.env, set by you), single-tenant build-time:
A mismatch between
VITE_SITE_DRAFT_SECRET (built into the editor) and DRAFT_MODE_SECRET (read by the site at runtime) is the single most common failure — avocado-register surfaces it as a warning, and the orchestrator’s /sites/register response includes a warnings array for the same reason.
Troubleshooting
Low-level primitives
IfcreateEditorApiHandler and createSitePage are too opinionated for your project — for example you have a custom routing layer, you mount the editor API at a non-standard path, or you need to compose draft mode with your own middleware — the same building blocks are exported individually:
{ GET, POST, OPTIONS } object you mount at any route you like. fetchEditorPage(slug, session, siteId) and fetchEditorSlugs(session, siteId) are the primitives createSitePage calls internally — use them directly inside your own page component if you need to compose them with other data sources.
The contract these primitives implement is the same one createEditorApiHandler mounts:
- Block manifest:
GET /api/editor/blocks(or wherever you mount it) - Pages snapshot:
GET /api/editor/pages - Draft enter:
GET /api/editor/draft?secret=...&redirect=/...— must validate the secret and reject non-internal redirects - Draft exit:
GET /api/editor/draft/disable?redirect=/... - Publish:
POST /api/editor/publish
VITE_SITE_ORIGIN and the bootstrap URL builder accordingly — the editor expects the standard paths by default.
Optional: dedicated /preview/* route group
If you want stronger isolation between published and draft content (e.g. a separate route group with its own middleware, layout, or feature flags), you can add a/preview/* route group that calls into fetchEditorPage directly. This is opt-in and not part of the standard onboarding path — most adopters don’t need it because Draft Mode cookies already give you per-request isolation.