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
- You define a block manifest — an array of
BlockDefinitionobjects describing your block types - You pass it to the editor API handler via
getManifest() - You register the same types with the block registry via
registerBlocks, which is what validates an edit - The AI planner, property panel, and block picker in the editor all derive their behavior from your manifest
- Your site renders blocks with your own React components
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:
createOrchestrator({ registerBlocks }).
Both re-run the hook after the built-in schemas have registered, so your
definitions land on top.
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: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: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() 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:- 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. ImportbuildBlockManifest() for the defaults and merge:
Checklist
- Create
lib/manifest.tswith yourBlockDefinition[] - Pass
getManifesttocreateEditorApiHandler() - Verify
/api/editor/blocksreturns your blocks - the editor header shows Manifest (not Degraded)
- Add block picker shows your block types
- Call
registerBlock()for every custom type and passregisterBlocksto the same handler - 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)