ChaiBuilder Logo

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' } },
  },
}),
  • title is the field's label. Without it the editor sees the raw prop name.
  • description on a property renders as helper text under the field.
  • default is what a fresh instance gets, and what the renderer falls back to.
  • enum gives a select; enumNames supplies friendlier labels in the same order.
  • ui is 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:

  1. blockProps goes on the outermost element, once. Two elements carrying it breaks selection in the canvas.
  2. Spread each style object on the element it styles. {...styles} already includes className.
  3. Never add a bare className next to a spread style object - the later prop wins and silently drops the editor's classes.
  4. 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:

  • blocks is 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: true keeps the children out of the Add Block panel. They arrive with the parent and are never added on their own.
  • canAcceptBlock on the parent plus the canMove / canDelete / canDuplicate trio 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:

  1. The block appears in the Add Block panel, in the group you named.
  2. Its settings form shows every non-style prop, with the labels you set.
  3. The Styles panel lists one target per stylesProp you declared.
  4. Selecting the block in the canvas highlights it - if not, blockProps is on the wrong element or on more than one.
  5. Publish the page and load it in a fresh browser: the block renders, and the classes the editor set are present in the HTML.
  6. 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.

© ChaiBuilder. All rights reserved.