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.How it works
- You register each block type with the block registry —
registerBlock(type, { schema, meta })— inside aregisterBlocksfunction. The registry is what validates an edit - You pass
registerBlocksto the editor API handler (and tocreateOrchestratorin library mode) - 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 - You pass
blockTypesto both handlers, so the manifest lists only the blocks your site renders - Your site renders blocks with your own React components
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 withregisterBlock (step 3), you can skip step 1 and leave getManifest out.
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:
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: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.
Per type, meta.role: "element" does the same.
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.
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.
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.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
AlistFields 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: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
BlockMeta — see the block system.
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, 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: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. Mapblock.type to your React components:
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:- Fetch: Query your CMS → convert to
PageDocwithBlockInstance[] - Publish: Receive
PageDoc[]→ write back to your CMS - Image fields: Use
imageFieldsfrom your manifest (notgetImageFields()from the shared registry)
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. WithregisterBlocks, 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:
Checklist
- Call
registerBlock()for every custom type and passregisterBlockstocreateEditorApiHandler()(andcreateOrchestrator()in library mode) - Pass the same
blockTypesto both handlers - Verify
/api/editor/blocksreturns your blocks and no others - The editor header shows no Limited badge
- Add block picker shows your block types, sections first
- Import
zfrom@avocadostudio-ai/site-sdk/blocks, not fromzod - AI can create, edit, and remove your blocks — an edit that answers
Unknown block typemeans the previous two are missing - Image fields are detected (check publish resolves images)