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

