Custom Blocks
A block is two things: a React component that renders, and a config that tells the builder what an editor may change. Registering the pair puts the block in the Add Block panel, generates its settings form, and makes the renderer able to draw it on a published page.
Everything on this page is the developer half of Blocks - read that first if you want the mental model rather than the API.
Anatomy
The convention is one folder per block under src/blocks/, with a barrel that registers
everything:
src/blocks/
├── index.ts # registerBlocks() - the single registration entry point
└── stat/
├── component.tsx # the registered component (server component by default)
├── config.ts # type, label, props schema, behaviour
└── stat-client.tsx # optional "use client" leaf for interactivity
Config lives in its own file for a reason: index.ts imports every config eagerly (it is
plain metadata, cheap) while each component is pulled in with next/dynamic, so a page only
loads the code for blocks it actually uses.
Build a block end to end
1. The component
// src/blocks/stat/component.tsx
import type { ChaiBlockComponentProps, ChaiStyles } from 'chaipro/types'
type StatProps = {
styles: ChaiStyles
labelStyles: ChaiStyles
value: string
label: string
}
const StatBlock = (props: ChaiBlockComponentProps<StatProps>) => {
const { blockProps, styles, labelStyles, value, label } = props
return (
<div {...blockProps} {...styles}>
<div>{value}</div>
<div {...labelStyles}>{label}</div>
</div>
)
}
export default StatBlock
Two props come from the builder rather than from your schema. blockProps carries the
attributes the canvas needs to select and highlight the block; styles carries the classes
the editor set in the Styles panel. Both always arrive as objects, so a || {} guard on the
spread is never needed - but styles.className is omitted when the resolved class list is
empty, so keep the ?. when you read it directly.
2. The config
// src/blocks/stat/config.ts
import { BarChart } from 'lucide-react'
import { registerChaiBlockProps, stylesProp } from 'chaipro/registry'
export const StatConfig = {
type: 'Stat',
label: 'Stat',
group: 'basic',
icon: BarChart,
description: 'A single headline number with a caption underneath.',
canAcceptBlock: () => false,
props: registerChaiBlockProps({
properties: {
styles: stylesProp('flex flex-col gap-1 text-center'),
labelStyles: stylesProp('text-sm text-muted-foreground'),
value: { type: 'string', title: 'Value', default: '10,000+' },
label: { type: 'string', title: 'Label', default: 'Happy customers' },
},
}),
i18nProps: ['value', 'label'],
}
3. Register it
// src/blocks/index.ts
import dynamic from 'next/dynamic'
import { registerChaiBlock } from 'chaipro/registry'
import { StatConfig } from './stat/config'
const StatBlock = dynamic(() => import('./stat/component')) as any
export const registerBlocks = () => {
registerChaiBlock(StatBlock, StatConfig)
}
4. Use it
Restart the dev server, open the builder, and Stat appears in the Add Block panel under
the basic group. Its Value and Label fields are in the settings panel; its two style props
are edited from the Styles panel, not the form.
Where registration has to run
registerChaiBlock writes into an in-memory registry. Every process that needs to know about
your block has to have run registerBlocks() first - and there is more than one:
| Surface | Why it needs the registry |
|---|---|
| The builder page | Draws the Add Block panel, the settings form, and the canvas |
| The builder's action route | Resolves default props and block schemas for save/AI actions |
| Every public page that renders blocks | RenderChaiBlocks looks the component up by type string |
So call it at module level - not inside a component, effect, or request handler - in each of those entry files:
// src/app/(site)/[[...slug]]/page.tsx
import { registerBlocks } from '@/blocks'
registerBlocks()
export default async function Page(props) { /* ... */ }
Module-level execution runs once per module graph and is idempotent: registering the same
type twice merges over the previous entry rather than throwing. That is also the supported
way to override a built-in block - register your own component under the existing type
string, or swap only the component with setChaiBlockComponent(type, Component) and keep the
stock config.
A block that renders fine in the builder but comes out blank on the live page is almost always
a missing registerBlocks() on the render entry.
Block config reference
Only type, label, and group are required.
| Option | Type | What it does |
|---|---|---|
type |
string |
Permanent identity, stored on every instance. See type strings are permanent |
label |
string |
Name shown in the Add Block panel and the layers tree |
group |
string |
Bucket in the Add Block panel - basic, typography, media, layout, form, advanced, other, or your own |
category |
string |
Defaults to core, and the Add Block panel lists only core blocks. Leave it alone unless a custom panel renders your category |
description |
string |
Context handed to the AI assistant. Not shown on the block tile |
icon |
component | Any React icon component - lucide, radix, react-icons |
hidden |
boolean |
Keeps the block out of the Add Block panel. Use for child blocks that only make sense inside a parent |
wrapper |
boolean |
Declares the block a container for other blocks; the AI assistant reads it when editing the tree |
blocks |
ChaiBlock[] | () => ChaiBlock[] |
A predefined subtree inserted when an editor adds the block |
pageTypes |
string[] |
Restricts the block to specific page types. Omit for "available everywhere" |
props |
registerChaiBlockProps(...) |
The editable props schema |
i18nProps |
string[] |
Props translated per language |
aiProps |
string[] |
Props the AI assistant may rewrite |
canAcceptBlock |
(type) => boolean |
Which child types may be dropped inside |
canBeNested |
(type) => boolean |
Which parent types this block may sit inside |
canMove / canDelete / canDuplicate |
() => boolean |
Editor guardrails |
dataProvider |
fn |
Server-side data fetch for this block |
dataProviderMode |
"live" | "mock" |
How the builder canvas gets the data. Defaults to mock |
dataProviderDependencies |
string[] |
Props that, when changed, re-run the provider |
The guardrails are editor ergonomics, not security. They shape what the builder offers; they are not enforced on saved data.
The props schema
registerChaiBlockProps takes a JSON-Schema-shaped object and splits it into the schema and
the UI hints the settings form needs. Each property becomes one field.
props: registerChaiBlockProps({
properties: {
styles: stylesProp('rounded-md border p-4'),
heading: { type: 'string', title: 'Heading', default: 'Our team' },
columns: { type: 'number', title: 'Columns', enum: [2, 3, 4], default: 3 },
layout: {
type: 'string',
title: 'Layout',
enum: ['grid', 'list'],
enumNames: ['Grid', 'List'],
default: 'grid',
},
showFilters: { type: 'boolean', title: 'Show Filters', default: true },
body: { type: 'string', title: 'Body', default: '', ui: { 'ui:widget': 'richtext' } },
},
}),
titleis the field's label. Without it the editor sees the raw prop name.descriptionon a property renders as helper text under the field.defaultis what a fresh instance gets, and what the renderer falls back to.enumgives a select;enumNamessupplies friendlier labels in the same order.uiis lifted out into a separate UI schema for you - put widget hints there.
Reserved and runtime prop names
The call throws at startup if a property collides with a name the framework owns:
- Reserved (stored on the block itself):
_type,_id,_parent,_bindings,_name - Runtime (injected at render):
$loading,blockProps,inBuilder,lang,draft,pageProps,pageData,children
This is a loud, immediate failure rather than a subtle one later - a block whose schema
declared children would silently fight the renderer.
Style props
stylesProp(defaultClasses) declares a prop the Styles panel owns:
styles: stylesProp('flex w-full flex-col bg-background'),
cardStyles: stylesProp('rounded-lg border p-6'),
A block can declare as many as it needs - one per element an editor should be able to restyle independently. Style props are filtered out of the settings form on purpose; they show up as targets in the Styles panel instead.
Defaults may reference design tokens with the dt#
prefix, and the renderer expands them before your component sees the classes:
inputStyles: stylesProp('dt#input dt#input-lg min-w-0 flex-1'),
buttonStyles: stylesProp('dt#btn dt#btn-primary shrink-0'),
That keeps a custom block on the site's token system instead of hardcoding a look.
UI widgets
Set ui: { 'ui:widget': '<name>' } on a property to change the control:
| Widget | Control |
|---|---|
richtext |
Rich text editor |
icon |
Icon picker (stores SVG markup) |
image |
Asset library image picker |
video |
Video picker |
code |
Code editor |
hidden |
Field hidden from the form |
textarea |
Multi-line text |
ui:placeholder sets placeholder text on any string field. String fields also get data
binding for free - an editor can type {{ page.title }} into them, see
Data Binding.
Grouping props in the settings panel
A block with a dozen props gets a long flat form. groupProps collects related props under
one collapsible section of the settings panel. Spread its result into properties:
import { groupProps, registerChaiBlockProps, stylesProp } from 'chaipro/registry'
props: registerChaiBlockProps({
properties: {
styles: stylesProp('rounded-md border p-4'),
heading: { type: 'string', title: 'Heading', default: 'Our team' },
...groupProps({
label: 'Layout',
properties: {
columns: { type: 'number', title: 'Columns', enum: [2, 3, 4], default: 3 },
layout: {
type: 'string',
title: 'Layout',
enum: ['grid', 'list'],
enumNames: ['Grid', 'List'],
default: 'grid',
},
},
}),
...groupProps({
label: 'Filters',
defaultOpen: false,
properties: {
showFilters: { type: 'boolean', title: 'Show Filters', default: true },
filterLabel: { type: 'string', title: 'Filter label', default: 'Filter by team' },
},
}),
},
}),
The editor sees Heading on its own, then a Layout section that starts open, then a Filters section that starts closed.
| Option | Type | What it does |
|---|---|---|
label |
string |
The section heading in the settings panel |
properties |
Record<string, schema> |
The props that go in the section, written exactly as they would be outside it |
defaultOpen |
boolean |
Whether the section starts expanded. Defaults to true |
Grouping is presentation only. The props stay top-level in the schema and in the saved
block; groupProps only adds a marker that tells the settings panel where to draw them. The
component above still reads props.columns, not props.layout.columns, and i18nProps and
aiProps keep naming the prop itself. So you can group, regroup or ungroup the props of a
block that is already on live pages without migrating any content.
How the form is laid out:
- A section sits where its first prop is declared. Ungrouped props render in declaration
order around it, so order
propertiesthe way the form should read. - The same label twice is one section. Two
groupPropscalls with the samelabelmerge, at the position of the first. - A section whose props are all hidden gets no heading. Style props and
ui:widget: 'hidden'fields never show in the form, so a group holding only those draws nothing. - Only top-level props group. The fields inside an
objectprop render the way they always have; put the whole object prop inside a group instead.
When a block has at least one section, an Expand all / Collapse all button appears at
the top of the settings panel. Which sections an editor opened or closed is remembered per
block type while the builder is open, so closing Filters on one instance closes it on
every block of that type. A reload goes back to each section's defaultOpen.
Use defaultOpen: false for settings most editors never touch, such as mobile overrides or
advanced behaviour, so the common fields are what they see first.
Subgroups
Spread a groupProps inside another group's properties and it becomes a subgroup, drawn as
a section inside the parent section:
...groupProps({
label: 'Image',
properties: {
image: { type: 'string', title: 'Image', default: '', ui: { 'ui:widget': 'image' } },
alt: { type: 'string', title: 'Alt text', default: '' },
...groupProps({
label: 'Mobile',
defaultOpen: false,
properties: {
mobileImage: { type: 'string', title: 'Mobile image', default: '', ui: { 'ui:widget': 'image' } },
mobileWidth: { type: 'string', title: 'Mobile width', default: '' },
},
}),
},
}),
A subgroup keeps its own defaultOpen, and its label only has to be unique inside its parent:
two sections can each have a Mobile subgroup.
Groups nest two levels deep and no further. Wrapping a subgroup in a third groupProps
throws at startup, naming the prop and the groups it is already in:
Groups nest at most two levels: "mobileImage" is already in group "Image" > "Mobile"
groupProps ships in chaipro v0.4.1, where it takes label and properties and does not
nest: a nested call throws Nested groups are not allowed. Subgroups and defaultOpen arrive
in the release after v0.4.1. See Releases and Upgrading.
Builder-only props
builderProp marks a control that exists to make editing possible and has no meaning on the
published page:
import { builderProp, registerChaiBlockProps } from 'chaipro/registry'
show: builderProp({ type: 'boolean', title: 'Open Modal (Preview)', default: false }),
A modal that is closed by default is impossible to style in the canvas. show opens it while
editing; the live component ignores it, because inBuilder is false there.
Read the flag as "this prop is for the canvas", not "this prop will be absent at render" - a
builderProp value is still written into the page JSON, so your component should ignore it
rather than assume it is undefined.
Reading a parent's prop
closestBlockProp(type, prop) declares a read-only prop resolved from the nearest ancestor of
a given type - useful when a child needs to know how its parent was configured:
parentTag: closestBlockProp('Repeater', 'tag'),
What the component receives
type ChaiBlockComponentProps<
BlockProps = unknown,
PageData = Record<string, unknown>,
> = ChaiBlock<BlockProps> & {
blockProps: Record<string, string>
inBuilder: boolean
lang: string
draft: boolean
$loading?: boolean
pageProps?: ChaiPageProps
pageData?: PageData
designTokens?: ChaiDesignTokens
children?: React.ReactNode
}
The second type parameter types pageData, so a block that expects a specific page-type
payload can declare it: ChaiBlockComponentProps<StatProps, ListingPageData>.
| Prop | Use it for |
|---|---|
blockProps |
Canvas attributes. Spread on the outermost element only |
inBuilder |
Branching between canvas and live behaviour - see below |
lang / draft |
Current language and whether this is a draft render |
pageProps |
The page's slug, identifier, and search params |
pageData |
Output of the page's data providers |
$loading |
True while the builder canvas is fetching this block's async props. Never set on a published page |
children |
Child blocks, when the block accepts them |
designTokens |
The site's resolved tokens, for subtrees the renderer cannot reach with props |
inBuilder is the escape hatch for anything the canvas cannot host honestly: a video that
should not autoplay while editing, a modal that renders inline instead of in a portal, a map
that would burn API quota on every keystroke. Keep the branch small - the further the canvas
drifts from the live page, the less the editor can trust what they see.
Styling rules
The registered component gets blockProps and one or more ChaiStyles objects. Four rules
cover every case:
blockPropsgoes on the outermost element, once. Two elements carrying it breaks selection in the canvas.- Spread each style object on the element it styles.
{...styles}already includesclassName. - Never add a bare
classNamenext to a spread style object - the later prop wins and silently drops the editor's classes. - Merge dynamic classes with
cn(), keeping the editor's classes last so they win:
<div {...blockProps} {...styles} className={cn(layoutClasses, styles?.className)} />
Move hardcoded classes out of the component and into the stylesProp() default. Anything
left in the JSX is a class the editor cannot change.
Server and client
The registered component renders as a React Server Component on the public page. Keep it that way: it is what lets a block read server data without shipping the fetch to the browser.
When a block needs state, effects, or event handlers, put those in a "use client" child and
have the server component pass props down:
// src/blocks/stat/component.tsx - server
import CounterClient from './stat-client'
const StatBlock = (props: ChaiBlockComponentProps<StatProps>) => (
<CounterClient blockProps={props.blockProps} styles={props.styles} value={props.value} />
)
// src/blocks/stat/stat-client.tsx
'use client'
const CounterClient = ({ blockProps, styles, value }) => {
/* hooks, listeners, animation */
}
Pass props explicitly rather than spreading everything through - a server component may not hand a function to a client child, and an accidental spread is the usual cause of that error.
Load heavy client dependencies inside the client leaf with a dynamic import() so they stay
out of the initial bundle.
Composite blocks
Some blocks are really a small structure: a modal is a trigger plus a panel, a carousel is slides plus controls plus pagination. The pattern is one parent plus hidden children, shipped together.
export const ModalConfig = {
type: 'Modal',
label: 'Modal',
group: 'advanced',
wrapper: true,
canAcceptBlock: (type: string) => type === 'ModalTrigger' || type === 'ModalContent',
blocks: () => [
{ _type: 'Modal', _id: 'modal' },
{ _type: 'ModalTrigger', _id: 'modal-trigger', _parent: 'modal' },
{ _type: 'Button', _id: 'modal-button', _parent: 'modal-trigger', content: 'Open' },
{ _type: 'ModalContent', _id: 'modal-content', _parent: 'modal' },
],
props: registerChaiBlockProps({ properties: { styles: stylesProp('w-max') } }),
}
export const ModalTriggerConfig = {
type: 'ModalTrigger',
label: 'Modal Trigger',
group: 'advanced',
hidden: true,
canMove: () => false,
canDelete: () => false,
canDuplicate: () => false,
canAcceptBlock: () => true,
props: registerChaiBlockProps({ properties: { styles: stylesProp('w-max') } }),
}
Three mechanics are doing the work:
blocksis the subtree inserted when the editor adds the parent. The first entry is the parent itself; children point at it with_parent, matching the parent's_id. Those ids are template-local - the builder assigns real ones on insert.hidden: truekeeps the children out of the Add Block panel. They arrive with the parent and are never added on their own.canAcceptBlockon the parent plus thecanMove/canDelete/canDuplicatetrio on the children keeps the structure intact while still letting editors style each part and drop their own content inside.
When a composite grows past two or three types, give it its own register<Name>Block()
function in the block's config.ts and call that from src/blocks/index.ts, instead of
listing every child in the barrel.
Fetching data
A block can fetch its own server data with dataProvider. The result is merged into the
component's props before it renders:
export const OfficeMapConfig = {
type: 'OfficeMap',
label: 'Office Map',
group: 'basic',
dataProviderMode: 'live' as const,
dataProviderDependencies: ['region'],
dataProvider: getOfficeMapData,
props: registerChaiBlockProps({ properties: { /* ... */ } }),
}
| Option | Effect |
|---|---|
dataProvider |
({ lang, draft, inBuilder, block, pageProps }) => data, sync or async |
dataProviderMode |
How the canvas gets the same data |
dataProviderDependencies |
Prop names that trigger a refetch when the editor changes them |
On the published page the renderer always calls dataProvider on the server and merges the
result into the component's props. The mode only decides what the builder canvas does:
live- the canvas asks the server for the block's props and never runs your function in the browser. This is what you want for anything that touches a database, a secret, or a server-only module.mock- the default. The canvas calls the registered function directly in the browser and expects a plain object back. Good for static sample data, wrong for anything server-side.
So a provider living in a "use server" module - the usual arrangement, since it keeps your
database driver out of the client bundle - must also set dataProviderMode: 'live' as const.
Under the default mock mode the canvas would invoke the client-side action stub and get a
promise instead of props.
The literal needs as const (or a satisfies ChaiBlockConfig on the whole config), otherwise
TypeScript widens it to string and the config no longer matches ChaiBlockConfig.
While the canvas is fetching, the component gets $loading: true; on a published page the
block suspends instead and $loading is never set.
Block-level providers are one of three levels; global and page-type data are covered in Data Providers.
Translations and AI
Two arrays mark props for two different systems, and a prop can appear in both:
i18nProps: ['heading', 'subheading', 'buttonText'],
aiProps: ['heading', 'subheading'],
i18nProps- stored per language. A translated site keeps one layout and one value per language for these props. See Multilingual.aiProps- the AI assistant may rewrite these, and only these.
Text an editor will actually write belongs in i18nProps. Aria labels and placeholders count
as text; a URL or an enum value does not.
Verify
After adding a block, walk this list:
- The block appears in the Add Block panel, in the group you named.
- Its settings form shows every non-style prop, with the labels you set, inside the
sections you declared with
groupProps. - The Styles panel lists one target per
stylesPropyou declared. - Selecting the block in the canvas highlights it - if not,
blockPropsis on the wrong element or on more than one. - Publish the page and load it in a fresh browser: the block renders, and the classes the editor set are present in the HTML.
- Check the server console for a schema error on boot - a reserved prop name throws there.
Gotchas
The guardrail names are exact. canDelete, canMove, canDuplicate, canAcceptBlock,
canBeNested. Anything else - canDeleteBlock, canAcceptBlocks - is silently ignored,
because the config object is a plain object and an unknown key is just an unknown key. A
guardrail that "does not work" is usually a misspelt one.
A block missing from the Add Block panel is usually category. The panel renders the
core category and nothing else, so a block filed under a category of its own is registered,
renderable, and invisible. hidden: true and a pageTypes list that excludes the current page
type produce the same symptom.
Changing a type string orphans content. Instances store the type; rename it and every
placed block stops resolving. Pick the name once.
Registering after render is too late. A block registered inside a component body or an effect may miss the render that needed it. Module level, always.
A style prop that never reaches an element is invisible. It shows up as a Styles target
but changes nothing. Every stylesProp should be spread somewhere in the component.
as any on the dynamic import is expected. next/dynamic cannot express the block
component signature; the cast is what the built-in blocks do too.
Defaults are snapshots, not live values. Changing a default later does not update blocks
already placed on a page - they carry the value from when they were added.
Related
- Blocks - the mental model behind this page
- Data Providers - global, page-type, and block-level data
- Design Tokens - the
dt#classes used in style defaults - Data Binding -
{{ }}expressions in block props - Extending the UI - custom panels, slots, and settings fields
- API - the full
chaiproentry point map

