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

# Next.js Integration

> Canonical onboarding path for any Next.js 15 or 16 site — two SDK helpers, one catch-all editor route, edit through Draft Mode without a /preview route.

This is the canonical onboarding path for any Next.js 15 or 16 (App Router) site. For background, see [Core Concepts](/concepts) and the [Integration Overview](/integration/sdk-surface).

**Goal**: keep your existing routes serving published content, enable AI editing through Next.js [Draft Mode](/concepts#draft-mode) cookies, and register the site so it shows up in the editor's dashboard. **No `/preview` route required.**

Related:

* [Environment reference](/reference/environment) — every variable, grouped by what it configures
* [Custom Blocks](/integration/custom-blocks) — register your own component types alongside (or instead of) the built-in blocks
* [Architecture](/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.

| Helper | What it gives you | Mount at |
| - | - | - |
| `createEditorApiHandler` | One catch-all route that serves `blocks`, `pages`, `draft`, `draft/disable`, and `publish` | `app/api/editor/[...path]/route.ts` |
| `createSitePage` | A `Page` component, `generateStaticParams`, and `generateMetadata` with draft mode, navigation, footer, editor overlay, and 404 handling already wired in | `app/[[...slug]]/page.tsx` |

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](#low-level-primitives) section shows the underlying handlers (`createBlocksHandler`, `createDraftEnableHandler`, `createDraftDisableHandler`, `fetchEditorPage`, `fetchEditorSlugs`).

## Walkthrough

<Steps>
  <Step title="Install the SDK">
    From your Next.js project root:

    ```bash theme={null}
    pnpm add @avocadostudio-ai/site-sdk
    # or: npm install @avocadostudio-ai/site-sdk
    ```

    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:

    ```bash theme={null}
    pnpm add @avocadostudio-ai/orchestrator-core
    ```

    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.

    <Warning>
      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`](/integration/custom-blocks), the preview attributes from `@avocadostudio-ai/site-sdk/markers`, the coverage gate from `@avocadostudio-ai/site-sdk/coverage`.
    </Warning>
  </Step>

  <Step title="Mount the catch-all editor API route">
    Create `app/api/editor/[...path]/route.ts`:

    ```ts theme={null}
    // app/api/editor/[...path]/route.ts
    import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
    import { getPages, publishPages } from "@/lib/my-cms" // your fetchers

    export const { GET, POST, OPTIONS } = createEditorApiHandler({
      // Required: return all published pages so the editor can seed
      // a fresh session with your real content. The same getter is re-read
      // on publish, as the baseline for counting what a publish removes.
      getPages: () => getPages(),

      // Optional: lets the editor publish edits back into your CMS.
      // Omit this and the editor runs in read-only / draft-only mode.
      onPublish: async (pages, config) => {
        await publishPages(pages, config)
        return { ok: true }
      },

      // Compared against the `x-publish-token` header. Not optional in
      // production: without it every publish is refused with 401.
      publishSecret: process.env.PUBLISH_TOKEN,

      // Optional: refuse a publish that removes more than this many pages.
      maxPagesRemoved: 2,
    })
    ```

    Two more options are worth knowing. `getSiteConfig` returns the site's name,
    logo and navigation, and its languages: declare `locales` and
    `defaultLocale` even when there is one, so a request to write in a language
    the site does not have produces a question instead of a translation.
    `editorOrigins` names editor origins allowed by CORS in code, in addition to
    `EDITOR_CORS_ORIGINS`.

    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](/integration/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. `session`, `siteId` and `editorOrigin` are read from inside `redirect` first and from the route's own query second, so `?secret=…&session=qa&siteId=my-site&redirect=/` works too. With no `DRAFT_MODE_SECRET` set it answers **503** `DRAFT_MODE_SECRET is not set`
    * `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](/integration/publishing) is where both rules are written down,
    including what the refusals say and how to get past them on purpose.
  </Step>

  <Step title="Replace your page route with createSitePage">
    Create (or replace) `app/[[...slug]]/page.tsx`:

    ```tsx theme={null}
    // app/[[...slug]]/page.tsx
    import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
    import { getPage, getSlugs, getSiteConfig } from "@/lib/my-cms"

    const { Page, generateStaticParams, generateMetadata } = createSitePage({
      siteId: "my-site",            // any kebab-case ID, must match avocado-register
      siteName: "My Site",          // optional, used for og:site_name
      siteUrl: process.env.NEXT_PUBLIC_SITE_URL,  // optional, but see Metadata and SEO
      getPage,                      // (slug: string) => Promise<PageDoc | null>
      getSlugs,                     // () => Promise<string[]>
      getSiteConfig,                // () => Promise<SiteConfig>  (optional, for nav/logo)
    })

    export default Page
    export { generateStaticParams, generateMetadata }
    ```

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

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

    `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](#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.
  </Step>

  <Step title="Register the site with the orchestrator">
    In [library mode](/quickstart) 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:

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

    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. Take the draft secret from `--secret`, else from `DRAFT_MODE_SECRET` in `.env` or `.env.local`, else generate one.
    2. POST your site config and that secret to `${ORCHESTRATOR_URL}/sites/register`, which answers whether the secret matches the orchestrator's own `DRAFT_MODE_SECRET`.
    3. **On a mismatch, stop and write nothing**, saying where the right value lives. Re-run with `--secret <value>`.
    4. Otherwise write `DRAFT_MODE_SECRET`, `NEXT_PUBLIC_DEFAULT_SITE_ID`, `NEXT_PUBLIC_SITE_NAME` and `NEXT_PUBLIC_EDITOR_ORIGIN` to `.env.local` where missing — `--secret` replaces a different secret already there — and `ORCHESTRATOR_URL` once the POST has been answered. An address nothing replied at is a guess, and the next run reads this file before falling back to `:4200`.

    If the POST can't connect, the CLI still writes the env file, says the secret could not be checked, and exits 0 — 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. It also warns when `.env.local` is not git-ignored.

    All flags: `npx avocado-register --help`. Common ones: `--id`, `--port`, `--orchestrator`, `--secret`, `--session`, `--purpose`, `--preview-url`, and `--token` for an orchestrator with an access password.

    After it succeeds, the site appears in the editor's dashboard the next time you open or refresh `http://localhost:4100`.
  </Step>

  <Step title="Verify the contract">
    Start your dev server, then run these from a second terminal. All four should pass:

    ```bash theme={null}
    # 1. Block manifest is non-empty
    curl -s http://localhost:3000/api/editor/blocks | jq '.version, (.blocks | length)'
    # → 1
    # → 20  (or whatever your custom registry returns)

    # 2. Pages endpoint returns your CMS content
    curl -s http://localhost:3000/api/editor/pages | jq '.pages | length'
    # → number > 0

    # 3. Valid secret enters Draft Mode and redirects with a draft cookie
    curl -i "http://localhost:3000/api/editor/draft?secret=$(grep DRAFT_MODE_SECRET .env.local | cut -d= -f2)&redirect=/" \
      | grep -i 'set-cookie\|location'
    # → set-cookie: __prerender_bypass=...
    # → location: /

    # 4. Wrong secret is rejected
    curl -i "http://localhost:3000/api/editor/draft?secret=wrong&redirect=/" \
      | head -1
    # → HTTP/1.1 401 Unauthorized
    ```

    And one negative check that's worth running by hand because it's the security-critical one:

    ```bash theme={null}
    # 5. External redirects are rejected (open-redirect protection)
    curl -i "http://localhost:3000/api/editor/draft?secret=<your-secret>&redirect=https://evil.com" \
      | head -1
    # → HTTP/1.1 307 Temporary Redirect, Location: /  (must NOT redirect to evil.com)
    #   An external target is coerced to "/", not rejected — a 307 to / is the pass.
    ```

    If all five behave as shown, the routes are wired. The last gate is
    [`npx avocado qa`](/integration/qa), which renders every page inside a frame
    on the editor's origin and exits non-zero until the integration works the way
    the editor will use it.
  </Step>

  <Step title="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](#troubleshooting).
  </Step>
</Steps>

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

## TypeScript types

The SDK re-exports the core types from `@avocadostudio-ai/shared`. Import what your fetchers need:

```ts theme={null}
import type { PageDoc, BlockInstance, SiteConfig } from "@avocadostudio-ai/site-sdk"

// Your CMS fetchers should be typed as:
async function getPage(slug: string): Promise<PageDoc | null> { /* ... */ }
async function getSlugs(): Promise<string[]> { /* ... */ }
async function getSiteConfig(): Promise<SiteConfig> { /* ... */ }
```

`PageDoc` has shape `{ id: string; slug: string; title: string; updatedAt: string; meta?: PageMeta; blocks: BlockInstance[] }` — `title` is required, and so is `updatedAt` on the wire. A content file rarely has a natural `updatedAt`, so `getPages` may leave it out: `/api/editor/pages` serves such a page with the Unix epoch (the same value on every read, meaning "unknown"), and the orchestrator stamps the time a page enters or changes in a session. A page missing anything else is named on the site's console and in the orchestrator's `POST /draft/bootstrap` answer, rather than silently left out. `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`:

```json theme={null}
{
  "version": 1,
  "blocks": [
    {
      "type": "Hero",
      "displayName": "Hero",
      "editablePaths": ["heading", "subheading", "ctaText", "ctaHref", "imageUrl", "imageAlt"],
      "propsSchema": { "type": "object", "properties": { "heading": { "type": "string" } } },
      "defaultProps": { "heading": "New hero heading" }
    }
  ]
}
```

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

```ts theme={null}
// 1. Manifest entry  (returned by GET /api/editor/blocks)
{ "type": "Hero", "propsSchema": { "type": "object" } }

// 2. Content block  (returned by your getPage())
{ "id": "b1", "type": "Hero", "props": { "heading": "Hello" } }

// 3. Renderer registry  (in your React tree, or the SDK's built-in registry)
const renderers = { Hero: HeroSection }
```

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:

```tsx theme={null}
const { Page } = createSitePage({ getPage, getSlugs })  // renders Avocado's built-in blocks
import "@avocadostudio-ai/blocks/styles.css"            // and Avocado's stylesheet
```

On a new site that is the entire point — you get twenty designed block types
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:

```tsx theme={null}
// app/preview-draft/[[...slug]]/page.tsx
export const dynamic = "force-dynamic"

import { requireEditorContext, fetchEditorPage } from "@avocadostudio-ai/site-sdk/draft"
import { EditorOverlay } from "@avocadostudio-ai/site-sdk/editor"
import { buildSlug } from "@avocadostudio-ai/site-sdk"
import { MyPageBuilder } from "@/components/PageBuilder"   // yours, unchanged
import { getPage } from "@/lib/my-cms"

export default async function PreviewPage({ params, searchParams }) {
  // First line, before anything unpublished is read: 404 unless this is an
  // authorized editor render (draft mode, or a valid secret in production).
  const editor = await requireEditorContext(await searchParams)
  const path = buildSlug((await params).slug)
  const page = (await fetchEditorPage(path, editor.session, editor.siteId)) ?? (await getPage(path))

  return (
    <>
      <MyPageBuilder blocks={page?.blocks ?? []} />
      <EditorOverlay slug={path} editorOrigin={editor.editorOrigin} />
    </>
  )
}
```

<Warning>
  **Gate the route before it reads.** A preview route that fetched every CMS
  draft with a server token and only then asked whether the request came from the
  editor showed any visitor who guessed its URL the site's unreleased pages.
  `requireEditorContext` answers with Next's `notFound()`, the same response a
  route that does not exist gives. Verify it the way a stranger would: request the
  preview URL from a production build with no cookie and no secret, and expect a
  404\.
</Warning>

Your components need **one** thing from you: a wrapper per block.

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

{blocks.map((block) => (
  <div key={block.id} id={block.id}
       {...(editorMode ? getPreviewWrapperProps(true, block.id, block.type) : {})}>
    <MyBlock {...block.props} />
  </div>
))}
```

Where `editorMode` is not in scope — a block component shared with the public
page, rendered two levels below the route — do not thread it down as a prop.
`const { block } = await getEditorMarkers()` from
`@avocadostudio-ai/site-sdk/draft` answers it from the request in any server
component, and `useEditorMarkers()` from `@avocadostudio-ai/site-sdk/markers/react`
does the same in a client one; see
[emitting nothing on public pages](/integration/inline-editing#emitting-nothing-on-public-pages).

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.

<Note>
  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](/integration/inline-editing).
</Note>

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

A field that renders as a single text node, which is most fields, is streamed
into React's own text node, so the next render reconciles normally. A field
with markup is replaced while it streams, and React's original nodes are put
back just before the next refresh. (Before 0.29.1 the overlay replaced the
nodes outright, and undo, redo and restores after a chat edit never reached the
preview until a reload.)

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

```tsx theme={null}
"use client"
import { useLivePreviewBlocks } from "@avocadostudio-ai/site-sdk/editor"

export function LiveBlocks({ fallback }) {
  const blocks = useLivePreviewBlocks()      // null when no provider is mounted
  return <MyPageBuilder blocks={blocks ?? fallback} />
}
```

Streamed edits then arrive as ordinary React state rather than as DOM writes,
which is what you want if your components own their markup.

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

## Rendering modes

`createSitePage` takes a `mode`:

| Mode | Rendering | Use it when |
| - | - | - |
| `"auto"` (default) | **Dynamic for every visitor.** Choosing between published and editor content means reading `searchParams` and draft mode, which opts the whole route out of static rendering — `generateStaticParams` is still returned and still has no effect. | Getting started, or a site that is dynamic anyway. |
| `"static"` | Genuinely static. No `searchParams`, no editor branching. | Production. Pair it with a preview route and the proxy below. |
| `"preview"` | Always dynamic, always `noindex`. Reads draft content. | The `/preview-draft/[[...slug]]` route that the proxy rewrites editor traffic to. |

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:

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

export default async function RootLayout({ children }) {
  const inEditor = await isEditorRender()
  return (
    <html lang="de">
      <body>
        {children}
        {!inEditor && <CookieConsent />}
        {!inEditor && <Analytics />}
      </body>
    </html>
  )
}
```

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

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:

| Tag | Source |
| - | - |
| `<title>` | `page.meta.title`, else `page.title` |
| `<meta name="description">` | `page.meta.description`, else the first block prop that reads like prose (`description`, `subheading`, `summary`, `body`, …), else a generated fallback. Markdown is stripped and the result capped at 160 characters. |
| `og:title` / `og:description` | The same title and description |
| `og:image` + `twitter:card` | `page.meta.ogImage`. A `summary_large_image` card is only claimed when there is an image to put in it. Resolved to an absolute URL when `siteUrl` is set. |
| `og:site_name` | The `siteName` you pass to `createSitePage` |
| `<link rel="canonical">` + `og:url` | `siteUrl` joined with the page's slug. Both are omitted entirely when `siteUrl` is unset. |

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.

```ts theme={null}
const { Page, generateStaticParams, generateMetadata } = createSitePage({
  siteId: "my-site",
  getPage,
  getSlugs,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,   // e.g. https://example.com
})
```

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:

```ts theme={null}
const { Page, generateStaticParams, generateMetadata } = createSitePage({
  siteId: "my-site",
  getPage,
  getSlugs,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
  metadata: (derived, page) => ({
    ...derived,
    title: page?.slug.startsWith("/blog/") ? `${derived.title} — Blog` : derived.title,
  }),
})
```

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:

```ts theme={null}
// next.config.ts
import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"

export default withAvocado({
  images: {
    remotePatterns: [{ protocol: "https", hostname: "cdn.my-cms.com" }],
  },
})
```

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.

Your own image components have to tolerate those URLs too. An image chosen in
the editor can be a relative path or live on a host your CMS never uses, and a
component written for the CMS alone — a bare `new URL(url)`, a loader or blur
placeholder built from the CMS's image API — throws on the first one. On one
integration that took down every post whose image had been changed in the
editor. Parse defensively and fall back to a plain image when the CMS-specific
path cannot apply.

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

```js theme={null}
// next.config.js
const nextConfig = { /* your config */ }

module.exports = async () => {
  const { withAvocado } = await import("@avocadostudio-ai/site-sdk/next-config")
  return withAvocado(nextConfig)
}
```

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

#### Wrapping a config that is already wrapped

Most real configs already pass through something — `withPlugins`,
`withBundleAnalyzer`, `withSentryConfig`. Leave that expression exactly as it
is, bind it to a name, and wrap the name in the file's last lines:

```js theme={null}
// next.config.js — everything above this line is untouched
const existingConfig = withPlugins([/* unchanged */], nextConfig)

module.exports = async (phase, context) => {
  const { withAvocado } = await import("@avocadostudio-ai/site-sdk/next-config")
  const resolved = typeof existingConfig === "function" ? await existingConfig(phase, context) : existingConfig
  return withAvocado(resolved)
}
```

`withAvocado` takes a config object, and composers like `withPlugins` return a
function of the build phase — hence the `typeof` line. What not to do is move
`withAvocado` inside the existing chain: re-indenting the body turned one
integration's 15-line change into a 121-line diff that nobody could review.

<Note>
  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`.
</Note>

## Framing

The editor loads your site in an iframe, at `<site>/<slug>?__editor=1`. A site
that sends `X-Frame-Options` or a CSP `frame-ancestors` without the editor's
origin shows a blank frame, and the console says "refused to display … in a
frame".

`withAvocado` writes the framing rules for you, as two `headers()` entries:

| Requests | Headers |
| - | - |
| With `__editor` in the query | `Content-Security-Policy: frame-ancestors 'self' <editor origins>` |
| Without it | `Content-Security-Policy: frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` |

The editor origins come from `EDITOR_CORS_ORIGINS` and
`NEXT_PUBLIC_EDITOR_ORIGIN` — the same list the editor API's CORS uses, so the
two cannot disagree. Outside production `http://localhost:4100` is assumed.
Every loopback origin is listed under both spellings, `localhost` and
`127.0.0.1`, because `frame-ancestors` compares origins as strings and the CLI
prints the second.

Note what this means for your public pages: with the default, they are sent
with `X-Frame-Options: SAMEORIGIN`. Your own `headers()` entries are kept and
listed first. If your site already sets its own framing headers, check that
the editor render still admits the editor's origin — `npx avocado qa` does in
its preflight — or pass `withAvocado(nextConfig, { framing: false })` and write
both rules yourself. Put a `missing: [{ type: "query", key: "__editor" }]` on
your public rule: without it both rules match an editor request, the browser
receives two CSP headers, and it enforces their intersection, which blocks the
frame again. `withAvocadoFraming` and `avocadoEditorOrigins` are exported from
`@avocadostudio-ai/site-sdk/next-config` if you want only this part.

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

```ts theme={null}
// next.config.ts
import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"

export default withAvocado({ /* your config */ })
```

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

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:

```bash theme={null}
curl -sI http://localhost:3000/api/editor/blocks | head -2
HTTP/1.1 308 Permanent Redirect
location: /api/editor/blocks/
```

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

```ts next.config.ts theme={null}
export default withAvocado({ trailingSlash: true })
// → also sets skipTrailingSlashRedirect: true
```

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:

<CodeGroup>
  ```ts Next.js 16 — proxy.ts theme={null}
  export const proxy = createEditorProxy({ trailingSlash: true }).proxy
  ```

  ```ts Next.js 15 — middleware.ts theme={null}
  export const { middleware, config } = createEditorMiddleware({ trailingSlash: true })
  ```
</CodeGroup>

`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](#sites-that-already-have-a-middleware) instead; you cannot have two.

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

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.

```bash theme={null}
curl -so /dev/null -w '%{http_code} %{redirect_url}\n' localhost:3000/about
# 308 http://localhost:3000/about/
curl -so /dev/null -w '%{http_code}\n' localhost:3000/about/
# 200
curl -so /dev/null -w '%{http_code}\n' localhost:3000/api/editor/blocks
# 200
```

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:

```ts proxy.ts theme={null}
export const proxy = createEditorProxy({ draftCookie: false }).proxy
```

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.

`draftCookie` also takes a cookie name. Keying the rewrite on Avocado's own
session cookie, `editor_draft_session`, rather than on Next's shared one is
what the Contentful blog integration did — Contentful's live preview and
Avocado both enable Next's Draft Mode there, and every Contentful preview
request was being rewritten into Avocado's route:

```ts proxy.ts theme={null}
export const proxy = createEditorProxy({ draftCookie: "editor_draft_session" }).proxy
```

A site with a CMS live-preview SDK usually has a second problem in the same
place: the SDK refuses to run when framed by anyone but the CMS's own app.
Contentful's `ContentfulLivePreviewProvider` throws "The current origin is not
supported" inside the editor frame, and `enableInspectorMode={false}` does not
skip that check. Pass `targetOrigin={[editorOrigin]}` in the layout Avocado's
preview renders, or do not mount the provider on editor renders.

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

```ts middleware.ts theme={null}
import { NextResponse, type NextRequest } from "next/server"
import { trailingSlashRedirect, editorPreviewRewrite } from "@avocadostudio-ai/site-sdk/middleware"
// Next 16: the same two, from "@avocadostudio-ai/site-sdk/proxy"

export function middleware(request: NextRequest) {
  // Stands in for a redirect Next would have issued before middleware ran, so
  // it goes first: rewriting before it would serve `/about?__editor=1` content
  // the site only publishes at `/about/`. Drop this line if you do not set
  // `trailingSlash: true`.
  const canonical = trailingSlashRedirect(request)
  if (canonical) return canonical

  // `null` means "not an editor request" — your cue to run your own logic,
  // not a decision to pass the request through.
  const editor = editorPreviewRewrite(request)
  if (editor) return editor

  return myExistingMiddleware(request) ?? NextResponse.next()
}
```

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.

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

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

<CodeGroup>
  ```ts Next.js 15 — middleware.ts theme={null}
  import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"

  export const { middleware, config } = createEditorMiddleware()
  ```

  ```ts Next.js 16 — proxy.ts theme={null}
  import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"

  export const proxy = createEditorProxy().proxy

  // Must stay a literal — Next 16 cannot read `config` from a factory result.
  export const config = {
    matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
  }
  ```
</CodeGroup>

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

| Variable | Where it lives | Purpose |
| - | - | - |
| `DRAFT_MODE_SECRET` | site `.env.local` | Validates `?secret=` on Draft Mode entry. Generated by `avocado-register` if missing. |
| `ORCHESTRATOR_URL` | site `.env.local` | Where the SDK fetches draft pages from. Unset, the SDK uses a library-mode orchestrator mounted in the same process, else `http://127.0.0.1:4200` in development, else nothing. |
| `NEXT_PUBLIC_DEFAULT_SITE_ID` | site `.env.local` | The site ID this project corresponds to in the orchestrator. |
| `NEXT_PUBLIC_SITE_NAME` | site `.env.local` | Display name shown in the editor's site picker. |
| `NEXT_PUBLIC_EDITOR_ORIGIN` | site `.env.local` | Editor origin for postMessage trust checks. Defaults to `http://localhost:4100`. Also allowed through CORS on the editor API routes. |
| `EDITOR_CORS_ORIGINS` | site `.env.local` | Extra origins allowed to call the editor API routes, comma-separated. In production nothing is allowed unless it is named here or in `NEXT_PUBLIC_EDITOR_ORIGIN`; in development `http://localhost:4100` is assumed. |

Two more that `avocado-register` does not write, because neither has a value it
could guess — set them yourself, per deployment:

| Variable | Where it lives | Purpose |
| - | - | - |
| `NEXT_PUBLIC_SITE_URL` | site `.env.local` | The site's public origin. Pass it to `createSitePage` as `siteUrl` to get a canonical link, `og:url`, and absolute `og:image` URLs. |
| `PUBLISH_TOKEN` | site `.env.local` | Pass it to `createEditorApiHandler` as `publishSecret`. The orchestrator sends the same value as `x-publish-token`; without it the publish route refuses every request under `NODE_ENV=production`. |
| `ORCHESTRATOR_ACCESS_TOKEN` | site `.env.local` | Needed only when the site reads drafts over HTTP from an orchestrator with an access password. See below. |

**Where draft reads go.** `fetchEditorPage`, `fetchEditorSlugs` and
`fetchEditorSiteConfig` — and so `createSitePage` — read a library-mode
orchestrator **in-process** when `createOrchestrator()` runs in the same
process and `ORCHESTRATOR_URL` is unset or names that mount's own path. Those
reads skip the access gate, so a password-protected production mount needs no
extra configuration. They go over HTTP, and need `ORCHESTRATOR_ACCESS_TOKEN` in
production, when `ORCHESTRATOR_URL` names another orchestrator, when a call
passes its own `orchestratorUrl`, or when the orchestrator route has not loaded
in the rendering process — separate serverless functions, for example. The SDK
logs a warning naming these cases when a read answers 401.

And on the editor's side. With `npx @avocadostudio-ai/cli start` these are flags — `--preview`, `--draft-secret`, `--publish-token` — and a hosted editor (a non-loopback `--host`) writes no credential into its page at all: after sign-in it fetches them from the orchestrator's `GET /editor/credentials`, which returns the orchestrator's own `DRAFT_MODE_SECRET` and `PUBLISH_TOKEN`. A library-mode orchestrator already holds the site's secret, so there is nothing to add. From a source checkout they are build-time variables in `apps/editor/.env`:

| Variable | Purpose |
| - | - |
| `VITE_SITE_ORIGIN` | Where the editor's iframe loads from |
| `VITE_SITE_DRAFT_SECRET` | **Must equal** the site's `DRAFT_MODE_SECRET` — the editor uses this to construct the bootstrap URL the iframe loads |

A mismatch between the editor's secret and the site's `DRAFT_MODE_SECRET` is the single most common failure. `avocado-register` stops on it before writing anything, and the orchestrator's `/sites/register` response carries `secretCheck` and a `warnings` array for the same reason.

## Troubleshooting

| Symptom | Likely cause | Fix |
| - | - | - |
| Editor iframe loads but no edits work, header shows a **Limited** badge | Manifest route not mounted or returning empty | `curl http://localhost:3000/api/editor/blocks` — should return `version` + non-empty `blocks` array. Check that `app/api/editor/[...path]/route.ts` exists and exports `GET`. |
| `curl /api/editor/draft?secret=…` returns **401** even with the right secret | The site's `DRAFT_MODE_SECRET` doesn't match what you're passing | `cat .env.local \| grep DRAFT_MODE_SECRET` — make sure you copied the *full* value, not just a prefix. Restart the Next.js dev server after editing `.env.local`. |
| Editor opens the iframe but the page renders **published** content, not the draft | Page loader isn't branching on Draft Mode | If you wrote the loader by hand, check it calls `(await draftMode()).isEnabled` and switches to `fetchEditorPage` / `fetchEditorSlugs`. **Or** switch to `createSitePage`, which does this for you. |
| Iframe loads, page renders, clicking a block does nothing | **Check this first.** Selection mode is off — it is the editor's default, and clicking is gated on it. Turn it on with the crosshair button in the chat composer (**Select an element**). In the iframe console, `document.documentElement.hasAttribute("data-editor-selection-mode")` says which state you are in; `data-editor-active` says whether the overlay mounted at all. Only if the first is `true` and clicks still do nothing is the markup worth suspecting. | |
| Iframe loads but `EditorOverlay` doesn't appear | Draft cookie not being sent because of `sameSite` | If your Next.js site is on a different origin from the editor, the draft cookie needs `SameSite=None; Secure`. The SDK helper sets `sameSite=lax` by default, which works for `localhost:3000` ↔ `localhost:4100` but not cross-origin HTTPS. For production, mount the editor on the same domain or override the cookie via your own draft route. |
| `npx avocado-register` says **"could not reach the orchestrator"** | Nothing is listening where it looked. It defaults to `http://localhost:4200` — the **standalone** server's address, which is wrong if you mounted the orchestrator in your own app | In library mode, start your site's `dev` script and pass `--orchestrator http://localhost:3000/api/avocado`. Otherwise start your standalone instance, or pass `--orchestrator http://your-host:4200`. |
| `avocado-register` stops: the orchestrator has a `DRAFT_MODE_SECRET` and this site's does not match | The secret in your project's `.env.local` was generated, or is another project's | Re-run with `--secret <value>`, taking the value from the orchestrator's `DRAFT_MODE_SECRET` (in a source checkout, the root `.env`; the editor reads it as `VITE_SITE_DRAFT_SECRET`). Nothing was written, so there is nothing to undo. |
| `curl /api/editor/draft?secret=…` returns **503** "DRAFT\_MODE\_SECRET is not set" | The site has no draft secret at all | Set `DRAFT_MODE_SECRET` in `.env.local` to the value the editor sends, then restart the dev server. `avocado-register` writes it for you. |
| Edits land in the draft and the property panel, but the preview shows published content, with a password set on the orchestrator | The site reads drafts over HTTP and the orchestrator refuses it | In library mode, leave `ORCHESTRATOR_URL` unset or point it at the mount's own path, so reads stay in-process. Otherwise set `ORCHESTRATOR_ACCESS_TOKEN` on the site. See [Environment variables](#environment-variables). |
| Site registered but doesn't appear in the editor | The editor caches the site list in localStorage and only fetches `GET /sites` on mount | Hard-refresh the editor (Cmd+Shift+R). |
| The preview renders your site correctly, but the property panel says **"This block is not in the page the orchestrator returned"** | In library mode the orchestrator lives inside your site, so the editor's default orchestrator has never heard of it — it answers with a different site's pages | Set the site's **Orchestrator URL** (site settings → General) to where you mounted it, e.g. `https://your-site.com/api/avocado`. For a one-off, append `?orchestratorUrl=…` to the editor URL. Leave it empty for a site whose orchestrator is the editor's own. |
| The editor iframe is **blank**, console says "refused to display … in a frame" | A framing header refuses the editor's origin. The editor frames `<site>/<slug>?__editor=1` — not the preview route, so a rewrite-path exception never fires | Wrap your config in `withAvocado`, which keys the rule on the `__editor` query parameter, and name your deployed editor in `NEXT_PUBLIC_EDITOR_ORIGIN` or `EDITOR_CORS_ORIGINS`. If your site writes its own framing headers, see [Framing](#framing). |
| Custom React components render fine on the live site but the editor refuses to edit them | Component type isn't in the manifest | Register the component via `getManifest` — see [Custom Blocks](/integration/custom-blocks). |
| `import.meta.env.VITE_SITE_DRAFT_SECRET` change isn't picked up | Vite snapshots `import.meta.env.*` at startup | Restart the editor — `npx @avocadostudio-ai/cli start …`, or `pnpm dev:editor` from a source checkout. HMR doesn't re-evaluate `.env`. |
| Every page has the same `<title>` and no description | The route file exports `Page` and `generateStaticParams` but not `generateMetadata` | Add it: `export { generateStaticParams, generateMetadata }`. See [Metadata and SEO](#metadata-and-seo). |
| No `<link rel="canonical">` and no `og:url` anywhere in the HTML, and shared links show a blank card although the page declares an `ogImage` | `createSitePage` has no `siteUrl`, so it emits neither the canonical nor `og:url`, and leaves a relative `ogImage` relative | Pass `siteUrl: process.env.NEXT_PUBLIC_SITE_URL` and set the variable per deployment. See [Tell the SDK where the site lives](#tell-the-sdk-where-the-site-lives). |
| `POST /api/editor/publish` answers **401** in production but works in dev | No `publishSecret` is configured. A publish route that overwrites the site's content fails closed in production; development stays open and warns once | Set `PUBLISH_TOKEN` on the site, pass it as `publishSecret`, and give the orchestrator the same value — it sends it as `x-publish-token`. See [Publishing](/integration/publishing). |
| A publish answers **409** and the editor says it would remove every page | The destructive-publish guard. An empty `pages` array is usually a client publishing what it thinks it has after its own state failed to load | If the site really should be emptied, resend with `allowDelete: true`. If not, reload the editor and check its page list first. See [Publishing](/integration/publishing). |
| An unknown URL renders a 404 page but returns HTTP **200** | A hand-written route returning a 404 body instead of calling `notFound()` | Use `createSitePage`, which calls `notFound()`, and add an `app/not-found.tsx` for your own chrome. |
| A generated or stock image 500s while the rest of the page renders | Its host is missing from `images.remotePatterns` | Wrap your config in `withAvocado`, which merges Avocado's image hosts in. See [Images](#images). |
| `require()` of `next-config.mjs` fails, or `ERR_REQUIRE_ESM` from `next.config.js` | The helper is ESM-only and your config is CommonJS | Export an async function and `await import` it — see [From a CommonJS `next.config.js`](#from-a-commonjs-nextconfigjs). |
| A `placehold.co` image 400s with "has type image/svg+xml" | The URL has no format extension, so the host returns SVG | Add `.png`: `https://placehold.co/768x512.png?text=Hero`. |
| Next 16 build fails with **"can't recognize the exported `config` field"** | `middleware.ts` with the destructured export; Next 16 reads `config` by static analysis | Rename to `proxy.ts` and inline the matcher — see [Next.js 16](#nextjs-16). |
| Every editor API call fails with a CORS error, and the routes answer fine under `curl` | The site sets `trailingSlash: true`, so `/api/editor/*` answers 308 and the preflight will not follow it | `withAvocado` sets `skipTrailingSlashRedirect`; add `createEditorProxy({ trailingSlash: true })` to put the page redirect back. See [Trailing slashes](#trailing-slashes). |
| Every page redirects forever in a browser, but `curl -I` shows one ordinary 308 | A hand-written trailing-slash redirect built from `request.nextUrl.clone()`; `NextURL` strips the slash again when it serialises | Use `trailingSlashRedirect(request)` from the SDK, or build the URL with `new URL(request.url)`. See [Sites that already have a middleware](#sites-that-already-have-a-middleware). |
| Your CMS's own preview (Sanity Presentation, Contentful live preview) started rendering Avocado's draft route | Both features use Next's `__prerender_bypass` cookie, and the editor proxy rewrites on it | Pass `createEditorProxy({ draftCookie: false })`. See [Sites that already use Draft Mode](#sites-that-already-use-draft-mode). |
| Editor API routes answer CORS-approved requests in production but not from your deployed editor | In production only origins you name are allowed | Set `NEXT_PUBLIC_EDITOR_ORIGIN` (or `EDITOR_CORS_ORIGINS`) to your editor's origin. |

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

```ts theme={null}
import {
  createBlocksHandler,
  createPagesHandler,
  createPublishHandler,
  createDraftEnableHandler,
  createDraftDisableHandler,
} from "@avocadostudio-ai/site-sdk/routes"

import { fetchEditorPage, fetchEditorSlugs } from "@avocadostudio-ai/site-sdk/draft"
```

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.

`requireEditorContext(searchParams)` and `resolveEditorContext(searchParams)` from `@avocadostudio-ai/site-sdk/draft` answer whether a request is an editor render: the first answers 404 when it is not, the second returns `null`. Use the first in any route that exists only to render drafts.

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.


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