Skip to main content
This is the canonical onboarding path for any Next.js 15 or 16 (App Router) site. For background, see Core Concepts and the Integration Overview. Goal: keep your existing routes serving published content, enable AI editing through Next.js Draft Mode cookies, and register the site so it shows up in the editor’s dashboard. No /preview route required. Related:

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 (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:
It is an optional peer, which means no package manager installs it for you, and @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.
Nothing else. The SDK does depend on @avocadostudio-ai/blocks, @avocadostudio-ai/preview-adapter, @avocadostudio-ai/shared and zod, but you cannot import any of them — a dependency of a dependency is not a specifier your own source may use. Under pnpm’s isolated node_modules they live in .pnpm/, where the SDK can reach them and your site cannot; under npm’s flat hoisting the import resolves today and breaks the first time an unrelated dependency change re-hoists.Everything an integration actually needs from them is re-exported by the SDK: registerBlock, z and the block-meta types from @avocadostudio-ai/site-sdk/blocks, the preview attributes from @avocadostudio-ai/site-sdk/markers, the coverage gate from @avocadostudio-ai/site-sdk/coverage.
2

Mount the catch-all editor API route

Create app/api/editor/[...path]/route.ts:
This single file exposes:
  • GET /api/editor/blocks — block manifest (auto-built from the SDK’s built-in registry, override via getManifest for custom blocks)
  • GET /api/editor/pages{ pages: PageDoc[] } for editor session bootstrap
  • GET /api/editor/draft?secret=...&redirect=... — Draft Mode entry, validates secret against DRAFT_MODE_SECRET, only allows internal redirects
  • GET /api/editor/draft/disable?redirect=... — Draft Mode exit
  • POST /api/editor/publish — receives published pages back from the editor
Secret validation, internal-redirect enforcement, CORS preflight, and the draft cookie are all handled by the helper. You do not implement these yourself.The publish route refuses two kinds of request before your 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:
Any route more specific than the catch-all keeps winning. An existing app/page.tsx still serves /, and app/about/page.tsx still serves /about — Next reports no conflict and logs nothing, so the pages you moved into Avocado look unchanged and the integration looks dead. Delete or move every route whose content now comes from getPage.
Export generateMetadata, not just generateStaticParams. Without it every page inherits whatever <title> your root layout sets, 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.
createSitePage handles, in order:
  • Detecting Draft Mode via next/headers and switching reads to fetchEditorPage / fetchEditorSlugs (which call the orchestrator)
  • Building site nav/header chrome from getSiteConfig
  • Rendering blocks via the SDK’s renderBlocks and the shared block library
  • Mounting the live EditorOverlay when 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
Because the factory calls 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 dev script running is the whole prerequisite. Otherwise point at your standalone or hosted instance. From your Next.js project directory:
Omit --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:
  1. Generate a DRAFT_MODE_SECRET if .env.local doesn’t already have one (32 random bytes, hex-encoded).
  2. Write DRAFT_MODE_SECRET, NEXT_PUBLIC_DEFAULT_SITE_ID, NEXT_PUBLIC_SITE_NAME, NEXT_PUBLIC_EDITOR_ORIGIN to .env.local if missing (existing values are never overwritten).
  3. POST your site config to ${ORCHESTRATOR_URL}/sites/register.
  4. Write ORCHESTRATOR_URL too, 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.
Steps 1–2 don’t depend on reaching anything. If the POST can’t connect, the CLI reports which half happened and exits 0 rather than calling the run a failure — registration is what adds the name, preview URL and purpose to the orchestrator’s registry, and a library-mode mount already serves the one site it is mounted in without it.All flags: 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:
  1. The AI generate an operation
  2. The preview update inside the iframe
  3. An undo entry appear in the history
If anything’s wrong, jump to Troubleshooting.
Open-redirect risk on /api/editor/draft. The redirect query parameter accepts the destination after Draft Mode is enabled. If you bypass createEditorApiHandler and roll your own route, you must reject anything that isn’t an internal path starting with /. An unvalidated redirect=https://evil.com would let an attacker craft a phishing link that briefly visits your domain (granting it credibility) before bouncing victims to a malicious page. The SDK handler enforces this for you — that’s the main reason to use it instead of writing the route by hand.

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:
If the manifest is missing or the route returns 404, the editor falls back to degraded mode — read-only preview with text-only edits, no add/remove/reorder/update-props operations. Use this as your “is the SDK actually wired in?” canary: a present manifest unlocks the full editing experience. To register your own components, see Custom Blocks — you pass a 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 stable type strings that must agree across three places:
If a block type appears in content but not in the manifest, it still renders on the published site, but the editor refuses structural ops on that specific block (degraded for that type only — the rest of the page stays editable).

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:
On a new site that is the entire point — you get eighteen designed blocks for free. On a site that already has a design system, following it replaces that design system with a generic block library, and the first thing the client sees is a site that is not theirs. An existing site wants the opposite: Avocado’s content pipeline, its own components. Do not call createSitePage and do not import the blocks stylesheet. Render the draft yourself:
Your components need one thing from you: a wrapper per block.
It carries 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 the data-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:
Streamed edits then arrive as ordinary React state rather than as DOM writes, which is what you want if your components own their markup — a React re-render and an innerHTML write to the same node fight, and React wins in a way that truncates text.
Two things to know before reaching for the React path. It re-renders on the client, so any renderer resolved from a server-only registry — including Avocado’s own getCustomRenderer map — blanks. apps/site therefore gates it behind an env var and disables it for pages containing custom-renderer blocks. Your own components, imported normally, are not affected by this; a registry you populate on the server is. It is also the newer of the two paths and the less exercised; if you are integrating for the first time, take the overlay path and revisit this once the rest works.

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 for parent.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:
It answers a rendering question, not an authorization one. What may read unpublished content is decided by resolveEditorContext, which requires draft mode or a valid secret. isEditorRender() only decides whether to mount third-party scripts — never gate content or credentials on it.
Reading the header opts the layout into dynamic rendering, which is already true of any layout that reads cookies, but is worth knowing before adding the call to a fully static one.

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.
Read it from the environment rather than writing the origin into the route, so a preview deployment describes itself instead of claiming to be production. A trailing slash is stripped once, on the way in, so the index page’s canonical is 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:
The derivation itself is exported from @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:
It reads 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:
Renaming the file to 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 binariesbetter-sqlite3 for session state, sharp for image processing. Bundling one produces a build that succeeds and a server that dies loading the .node file, 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 with Module not found over 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:
Setting serverExternalPackages yourself is not sufficient. transpilePackages — which library mode requires, because the Avocado packages ship TypeScript entry points — takes precedence over server externals for a transitive dependency. sharp reached through orchestrator-core is therefore bundled despite being listed. A webpack externals entry is what actually holds, and withAvocado adds both. Your own webpack hook still runs and still wins; pass withAvocado(nextConfig, { serverExternals: false }) — again the second argument — to take the whole thing over.
The list is exported as 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 your next.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
That alone would stop your site redirecting /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 createEditorProxytrailingSlash, 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.
These two are a pair. withAvocado cannot see your proxy and your proxy cannot read next.config, so nothing checks that you set both. With only the config half, every URL your site has published stops redirecting to its canonical form. Set the proxy flag in the same commit, and verify before you deploy.
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.
To keep Next’s redirect and handle the editor yourself, pass 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
The trade is that in-iframe navigation no longer rides on the cookie, so your links have to carry the parameter themselves. 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 export createEditorProxy, so it composes. Both halves are exported for that:
middleware.ts
Your matcher has to reach every path either half needs, and buildEditorMatcher() gives you the one the factory would have used if you want to merge it with your own.
Build the trailing-slash redirect from new URL(request.url), never from request.nextUrl.clone(). A NextURL re-applies trailing-slash normalisation when it serialises, so it strips the slash you just added and the redirect points back at the URL it came from — every page on the site in an infinite redirect.It reproduces only in a browser. curl without -L sees one perfectly ordinary 308 → /about/, because the stripping happens on serialisation and the header looks right. This is why the helper above exists: it is one line, and writing it yourself is a coin flip.

What this costs

On a site whose middleware previously ran only for requests that announced themselves — a has: [{ 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 the middleware 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.
The 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 by npx 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

If createEditorApiHandler 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:
Each factory returns a { 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
If you change the URL paths, update the editor’s 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.