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

# Custom Blocks

> Register your own block types with custom schemas, renderers, and props — the AI planner picks them up automatically.

# Custom Block Registration

This page is for the developer wiring a site's own components into Avocado
Studio. Use custom blocks when your site has its own component library and you
don't want the product's default block set (Hero, CTA, FeatureGrid, etc.).

**Default blocks** are a starter kit — convenient for new sites but not required. The product is content-model-agnostic: it works with any blocks you register.

<Tip>
  On a CMS-backed site, declare the blocks with a [field table](/integration/field-table)
  instead. It calls `registerBlock` for you and derives the projection and the
  publish merge from the same declaration.
</Tip>

## How it works

1. You register each block type with the **block registry** — `registerBlock(type, { schema, meta })` — inside a `registerBlocks` function. The registry is what validates an edit
2. You pass `registerBlocks` to the editor API handler (and to `createOrchestrator` in library mode)
3. The handler builds the **block manifest** from the registry and serves it at `GET /api/editor/blocks`. The AI planner, property panel and block picker derive their behavior from it
4. You pass `blockTypes` to both handlers, so the manifest lists only the blocks your site renders
5. Your site renders blocks with your own React components

The manifest and the registry are two different things. The manifest is what
the editor reads; the registry is what the operations engine validates against.
`getManifest` is optional: without it the handler serves `buildBlockManifest()`,
built from the registry, so one registration feeds both. A hand-written JSON
manifest with no registration behind it gets you a preview you cannot edit.

## Minimal example

The steps below start from a hand-written JSON manifest, the older shape, and
then register the schemas behind it. If you register your blocks with
`registerBlock` (step 3), you can skip step 1 and leave `getManifest` out.

### 1. Define your manifest

```ts theme={null}
// lib/manifest.ts
import type { BlockManifest } from "@avocadostudio-ai/site-sdk/editor-manifest"
import { getManifestImageFields } from "@avocadostudio-ai/site-sdk/routes"

const manifest: BlockManifest = {
  version: 1,
  blocks: [
    {
      type: "Hero",
      displayName: "Hero Banner",
      propsSchema: {
        type: "object",
        properties: {
          heading:  { type: "string" },
          subtitle: { type: "string" },
          imageUrl: { type: "string" },
          ctaText:  { type: "string" },
          ctaHref:  { type: "string" },
        },
        required: ["heading"],
      },
      defaultProps: {
        heading: "Welcome",
        subtitle: "Your tagline here",
        imageUrl: "",
        ctaText: "Get started",
        ctaHref: "#",
      },
    },
    {
      type: "FeatureGrid",
      displayName: "Feature Grid",
      propsSchema: {
        type: "object",
        properties: {
          heading: { type: "string" },
          features: {
            type: "array",
            items: {
              type: "object",
              properties: {
                title:       { type: "string" },
                description: { type: "string" },
                imageUrl:    { type: "string" },
              },
            },
          },
        },
      },
      defaultProps: {
        heading: "Features",
        features: [
          { title: "Fast", description: "Built for speed", imageUrl: "" },
          { title: "Simple", description: "Easy to use", imageUrl: "" },
        ],
      },
    },
  ],
}

export function getManifest() { return manifest }

// Derive image field metadata for CMS adapters (publish/fetch image resolution)
export const { imageFields, listImageFields, listFieldNames } = getManifestImageFields(manifest)
```

### 2. Wire into the editor API route

```ts theme={null}
// app/api/editor/[...path]/route.ts
import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
import { getManifest } from "../../../../lib/manifest"

export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages: () => fetchYourPages(),
  getManifest,
  onPublish: yourPublishHandler(),
})
```

### 3. Register the schemas with the operations engine

The manifest tells the editor what exists. It is **not** what validates an
edit. Every incoming operation is checked against the global block registry in
`@avocadostudio-ai/shared`, which starts out holding only the built-in types.
Skip this step and everything looks wired up until the first AI edit:

```json theme={null}
{"error":"Invalid props for PricingTable: Unknown block type: PricingTable","errorCode":"schema_violation"}
```

Register each type, and hand the function to the same handler:

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

export function registerMyBlocks() {
  registerBlock("PricingTable", {
    schema: z.object({
      title: z.string().min(1),
      tiers: z.array(z.object({ name: z.string().min(1), price: z.string().min(1) })).min(1),
    }),
    meta: {
      displayName: "Pricing Table",
      fields: { title: { kind: "text" } },
      listFields: {
        tiers: { itemFields: { name: { kind: "text" }, price: { kind: "text" } } },
      },
    },
  })
}
```

```ts theme={null}
// app/api/editor/[...path]/route.ts
export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages: () => fetchYourPages(),
  getManifest,
  registerBlocks: registerMyBlocks,
  onPublish: yourPublishHandler(),
})
```

In library mode the same option goes to `createOrchestrator({ registerBlocks })`.
Both re-run the hook after the built-in schemas have registered, so your
definitions land on top.

### Offer only the blocks you render

Importing anything from the SDK registers Avocado's 18 insertable built-ins
alongside your own types, whether or not your site has a renderer for them.
Declare your catalogue, and pass it to **both** handlers:

```ts theme={null}
export const SITE_BLOCK_TYPES = ["PricingTable", "site_Hero", "site_Faq"]

createEditorApiHandler({ getPages, registerBlocks: registerMyBlocks, blockTypes: SITE_BLOCK_TYPES })
createOrchestrator({ adapter, registerBlocks: registerMyBlocks, blockTypes: SITE_BLOCK_TYPES })
```

Without it, `GET /api/editor/blocks` offers `Hero`, `FeatureGrid` and the rest,
and an editor who adds one gets a block that applies cleanly and renders
nothing. `blockTypes` also takes an object that sorts the add-block picker into
page sections and smaller building blocks — see
[sections, building blocks and the picker](/integration/cms-adapters#sections-building-blocks-and-the-picker).
Per type, `meta.role: "element"` does the same.

<Warning>
  **Import `z` from `@avocadostudio-ai/site-sdk/blocks`, not from `zod`.**

  `registerBlock` takes a `ZodObject`, and a Zod object is assignable only to one
  built by the *same copy* of the library. Your own `import { z } from "zod"`
  resolves to whatever your dependency tree hoisted — on any site that also uses
  Sanity, that is zod 3.x — and the mismatch surfaces as a structural type error
  listing methods you have never called:

  ```
  Type 'ZodObject<…, "passthrough", …>' is missing the following properties
  from type 'ZodObject<any, $strip>': loose, safeExtend, exactPartial, def,
  and 21 more
  ```

  Nothing in that message says you have two copies of zod. Importing `z` from
  `@avocadostudio-ai/site-sdk/blocks` gives you the instance the registry is typed
  against, and you need no direct `zod` dependency at all.
</Warning>

<Note>
  Do not instead put a side-effect `import "@/lib/register-blocks"` last in the
  route file and rely on ESM source order. Next's bundler does not reliably
  preserve that order across the RSC, SSR and route-handler layers, so the
  built-in schemas sometimes re-register on top of yours. The `registerBlocks`
  hook exists to replace that trick.
</Note>

### What `kind` may be

`meta.fields[…].kind` is a closed list, and it is **not** the list of HTML
element names. `"select"` and `"textarea"` are the two most natural guesses and
both are wrong:

| What you want | What you write |
| - | - |
| a single-line string | `{ kind: "text" }` |
| a multi-line box | `{ kind: "text", multiline: true }` — `multiline` is a flag, not a kind |
| markdown / rich text | `{ kind: "richtext" }` |
| a closed list of values | `{ kind: "enum", options: ["a", "b"] }` — a `string[]`, not `{ value, label }[]` |
| an image | `{ kind: "image" }`, with its alt text as `{ kind: "imageAlt" }` |
| a link | `{ kind: "url" }` for a bare href, `{ kind: "link" }` for a link picker over the site's own pages |
| markup the template renders with `dangerouslySetInnerHTML` | `{ kind: "html" }` — see [the field table](/integration/field-table#when-the-stored-value-is-html) |
| a list of strings, or of images | `{ kind: "stringList" }`, `{ kind: "imageList" }` |
| a pointer the CMS owns | `{ kind: "reference" }` — see below |
| an uploaded file | `{ kind: "file" }` |
| the rest | `{ kind: "number" }`, `{ kind: "boolean" }`, `{ kind: "color" }`, `{ kind: "headingLevel" }` |
| shown but never written | any kind with `readOnly: true, readOnlyReason: "…"` — see [read-only fields](/integration/block-system#fixed-blocks-and-read-only-fields) |
| the CMS's own bookkeeping | `{ kind: "text", internal: true }` — see below |

In TypeScript a wrong `kind` is a compile error naming the whole union. In
JavaScript it is silent: the property panel falls back to a plain text input and
the preview loses whatever affordance the right kind would have given the field.

### `reference`, and why it is not a `link`

A CMS stores an internal link as a pointer, not a URL. Storyblok holds
`{ linktype: "story", id: <uuid>, cached_url: "faq" }` and the Delivery API
renders it per language — `/faq` on the German page, `/fr/faq` on the French
one. Contentful entry links and Sanity references have the same shape.

Flatten one to an href, as `kind: "link"` invites, and the projection stops
being invertible in both directions at once:

* the publish diff reports **every reference on every page as changed**, forever,
  because the rendered href never equals the stored object;
* writing that href back replaces the pointer with a hard-coded URL. The page
  renders identically and the link silently stops following renames — the one
  thing the reference was for.

```ts theme={null}
storyLink: { kind: "reference", label: "Links to", referenceLabelKey: "cached_url" }
```

Declared this way the value is carried through untouched, the way
`avocadoUnknownBlock` carries a rich-text node the pivot cannot model. The
property panel shows where it points and offers no control; a planner is not
told the prop exists, and an `update_props` naming it is dropped with a note
saying the change belongs in the CMS.

`referenceLabelKey` is optional and purely a display concern — the common
spellings (`cached_url`, `slug`, `title`, `name`) are tried anyway, then the id.
Set it when the readable half sits under a name nothing would guess.

Re-pointing a reference from the editor needs a picker over the CMS's own
document ids, which only your integration has. Until there is one, showing the
target and refusing the edit is the honest answer; a text input is the
corrupting one.

### Props that are not content

Some props in a block are the storage system's, not a person's: Storyblok's
`_uid`, a Contentful `sys`, a `__source` snapshot, a revision stamp. The
publisher needs them, so they have to survive a round trip through the draft —
but nobody edits them, and a model must never be told they exist.

**A leading underscore already says this.** `_uid`, `_key`, `_type`, `__source`
are recognised by convention: no control in the panel, absent from what the
planner reads, no preview marker expected, never offered for translation, and
not a coverage finding. You do not have to declare them.

For anything that does not follow that convention, say it:

```ts theme={null}
revisionId: { kind: "text", internal: true },
sys:        { kind: "text", internal: true }   // an object prop, otherwise undescribed
```

`internal` is also the answer to a report full of `orphan_prop`. That finding
means "held in your content and described by nothing" — it is how you learn a
field is uneditable and invisible — and a bookkeeping prop on every block
produces one per block per page. One Storyblok integration's `_uid` generated
**\~800 of them**, which buried every real finding under noise that could not be
closed.

<Note>
  `internal` and `panelOnly` are different statements, and the weaker one is the
  easy mistake. `panelOnly: true` means *a person does edit this, just not on the
  page* — a `sectionId` that becomes an HTML `id`, a `<video poster>`. Reach for
  that when the field has no element. Reach for `internal` when the field has no
  audience.
</Note>

It is not access control. The value still rides in `props`, still round-trips
through the draft, and is still what your adapter writes back. What changes is
that nothing which talks to a human or a model mentions it — and an operation
that names one is dropped, because the planner was never shown it.

### Lists whose rows are not all the same shape

A `listFields` entry describes rows with **one** `itemFields` shape, which is
wrong for the common "page content is a sequence of headings, paragraphs, images
and CTAs" list — every row of every other shape renders the first shape's fields
against props it does not have.

There is a second form for exactly this. Name the row property that says what a
row is in `discriminator`, map each of its values in `itemFieldsByType`, and
leave `itemFields` as the fallback for a row whose type is not listed:

```ts theme={null}
listFields: {
  content: {
    label: "Content",
    discriminator: "type",
    itemFields: { type: { kind: "text" }, text: { kind: "text", multiline: true } },
    itemFieldsByType: {
      heading:   { text: { kind: "text", label: "Heading" } },
      paragraph: { text: { kind: "text", multiline: true } },
      image:     { src: { kind: "image" }, alt: { kind: "imageAlt" } },
      cta:       { label: { kind: "text" }, href: { kind: "url" } },
    },
  },
}
```

### 4. Verify

Start your dev server and check:

```bash theme={null}
curl http://localhost:3000/api/editor/blocks | jq '.blocks[].type'
```

Should print your block types, and only those. If it also prints `Hero`,
`FeatureGrid` and the rest, `blockTypes` is missing from this handler. If the
editor cannot read the manifest it shows a **Limited** badge in its header and
offers text edits only.

Then check the half the manifest cannot tell you about — ask the AI to change a
prop on one of your blocks. A `schema_violation` naming "Unknown block type"
means `registerBlocks` is not wired up.

## BlockDefinition reference

```ts theme={null}
type BlockDefinition = {
  type: string                          // Block identifier (PascalCase recommended)
  displayName?: string                  // Human-readable name in the editor UI
  editablePaths?: string[]              // Fields the AI can edit (optional hint)
  propsSchema: Record<string, unknown>  // JSON Schema describing block props
  defaultProps?: Record<string, unknown> // Defaults when adding a new block
  fields?: Record<string, FieldMeta>    // kind, label, multiline, flags — merged over the schema
  listFields?: Record<string, ListFieldMeta>
  category?: string
  description?: string
  chrome?: boolean                      // pinned: never added, moved or removed
  fixed?: boolean                       // drawn at a fixed position
  shared?: boolean                      // site-wide content, one per block id
  role?: "section" | "element"          // how the add-block picker offers it
  insertable?: boolean                  // false: renders and edits, never offered
  group?: string                        // a picker heading of the site's own
}
```

The flags mean what they mean on `BlockMeta` — see [the block system](/integration/block-system).

### `propsSchema`

Uses a JSON Schema subset. The product infers field types from schema + field name conventions:

| Field name pattern | Inferred type | the editor behavior |
| - | - | - |
| `*imageUrl`, `*Image`, `*imageSrc`, or exactly `src` / `logoUrl` / `heroImage` | Image | Asset Manager modal with Unsplash, Google Drive, CMS libraries, AI generation (OpenAI or Gemini), and local upload. See [Asset Manager & AI Images](/features/asset-picker). |
| `*Alt` | Image alt text | Text input |
| Field with `enum: [...]` | Enum | Dropdown selector |
| `type: "number"` | Number | Number input |
| Everything else (`type: "string"`) | Text | Text input, inline editable |
| `type: "array"` with `items.type: "object"` | List | Repeatable item list |

You don't need to explicitly declare field kinds — the product derives them from your schema, and a `fields` entry overrides the guess. Name image fields `*imageUrl` or `*Image` and they'll be detected automatically. Note that a bare `logo` or `companyLogo` is **not** detected — only the exact key `logoUrl`.

### `defaultProps`

Provide sensible defaults for every field. When a user asks the AI to "add a Hero block", these defaults are used as the starting point. The AI then modifies them based on the user's request.

### `editablePaths` (optional)

JSONPath-style strings hinting which fields the AI should focus on. Not required — the AI infers editability from the schema. Useful for complex blocks where you want to limit AI scope.

## Image handling

If your blocks have image fields, the manifest-driven image detection handles them automatically:

```ts theme={null}
import { imageFields } from "./manifest"

// In your CMS fetch adapter:
const imgs = imageFields.get("Hero") // Set<"imageUrl">
for (const [key, value] of Object.entries(blockProps)) {
  if (imgs.has(key)) {
    // Resolve CMS image reference to URL
    props[key] = resolveImageUrl(value)
  }
}
```

The `getManifestImageFields()` utility derives this from your `propsSchema` — fields matching the image name pattern are included automatically.

## Rendering blocks

The product doesn't render your custom blocks — your site does. Map `block.type` to your React components:

```tsx theme={null}
// components/BlockRenderer.tsx
import type { BlockInstance } from "@avocadostudio-ai/site-sdk"
import { Hero } from "./blocks/Hero"
import { FeatureGrid } from "./blocks/FeatureGrid"

const RENDERERS: Record<string, React.ComponentType<any>> = {
  Hero,
  FeatureGrid,
}

export function BlockRenderer({ block }: { block: BlockInstance }) {
  const Component = RENDERERS[block.type]
  if (!Component) return null
  return <Component {...block.props} />
}
```

Or use the SDK's `renderBlocks()` with renderers registered through
`registerCustomRenderer(type, Component)` from `@avocadostudio-ai/blocks`. It
returns one element wrapped in `EditorModeProvider`, not an array — render it
directly rather than spreading it.

## Using with a CMS

Custom blocks work with any CMS adapter. The pattern is the same as default blocks:

1. **Fetch**: Query your CMS → convert to `PageDoc` with `BlockInstance[]`
2. **Publish**: Receive `PageDoc[]` → write back to your CMS
3. **Image fields**: Use `imageFields` from your manifest (not `getImageFields()` from the shared registry)

The `create-avocado-site` scaffold supports custom blocks — run `npm create avocado-site@latest` inside your project, choose "Wire Avocado into this project", then "Custom blocks", and it generates a stub manifest for you to fill in.

## Mixing default and custom blocks

You can use both default blocks and custom ones. With `registerBlocks`, list the built-ins you render in `blockTypes` beside your own, and the generated manifest carries both. With a hand-written manifest, import `buildBlockManifest()` for the defaults and merge:

```ts theme={null}
import { buildBlockManifest } from "@avocadostudio-ai/site-sdk/editor-manifest"
import type { BlockManifest } from "@avocadostudio-ai/site-sdk/editor-manifest"

const defaults = buildBlockManifest()

const manifest: BlockManifest = {
  version: 1,
  blocks: [
    ...defaults.blocks,
    // Your custom blocks:
    { type: "PricingTable", displayName: "Pricing", propsSchema: { ... }, defaultProps: { ... } },
  ],
}
```

## Checklist

* [ ] Call `registerBlock()` for every custom type and pass `registerBlocks` to `createEditorApiHandler()` (and `createOrchestrator()` in library mode)
* [ ] Pass the same `blockTypes` to both handlers
* [ ] Verify `/api/editor/blocks` returns your blocks and no others
* [ ] The editor header shows no **Limited** badge
* [ ] Add block picker shows your block types, sections first
* [ ] Import `z` from `@avocadostudio-ai/site-sdk/blocks`, not from `zod`
* [ ] AI can create, edit, and remove your blocks — an edit that answers `Unknown block type` means the previous two are missing
* [ ] Image fields are detected (check publish resolves images)


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