Skip to main content

Custom Block Registration

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.

How it works

  1. You define a block manifest — an array of BlockDefinition objects describing your block types
  2. You pass it to the editor API handler via getManifest()
  3. You register the same types with the block registry via registerBlocks, which is what validates an edit
  4. The AI planner, property panel, and block picker in the editor all derive their behavior from your manifest
  5. Your site renders blocks with your own React components
The manifest and the registry are two different things and you need both. The manifest is what the editor reads; the registry is what the operations engine validates against. Step 2 alone gets you a preview you cannot edit.

Minimal example

1. Define your manifest

2. Wire into the editor API route

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:
Register each type, and hand the function to the same handler:
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.
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:
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.
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.

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

4. Verify

Start your dev server and check:
Should print your block types. The editor header should show Manifest (not Degraded). 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

propsSchema

Uses a JSON Schema subset. The product infers field types from schema + field name conventions: You don’t need to explicitly declare field kinds — the product derives them from your schema. 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:
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:
Or use the SDK’s renderBlocks() if you register renderers with the block system.

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. Import buildBlockManifest() for the defaults and merge:

Checklist

  • Create lib/manifest.ts with your BlockDefinition[]
  • Pass getManifest to createEditorApiHandler()
  • Verify /api/editor/blocks returns your blocks
  • the editor header shows Manifest (not Degraded)
  • Add block picker shows your block types
  • Call registerBlock() for every custom type and pass registerBlocks to the same handler
  • 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)