:::contentbit
How-to guides

Defining custom blocks

Add project-specific blocks in under 20 lines with defineBlock.

Every block is a BlockDefinition: a name, a props schema, a content model, and authoring guidance. The registry is the single source of truth — the same definition validates content, drives renderers, and generates LLM instructions. Define a block today and every LLM agent wired to this project writes it correctly on its next run, because skills read the registry live through the CLI.

A complete custom block

import { childBlocks, defineBlock, pipeRows } from '@contentbit/core'
import { z } from 'zod'

export const pricingTableBlock = defineBlock({
  name: 'pricing-table',
  description: 'Compares product plans.',
  props: z.object({
    currency: z.enum(['usd', 'eur', 'gbp']).default('usd'),
  }),
  content: pipeRows({
    columns: ['plan', 'price', 'features'],
    minRows: 2,
  }),
  authoring: {
    useWhen: ['Comparing SaaS pricing plans'],
    avoidWhen: ['Listing non-pricing feature differences'],
    example: `:::pricing-table{currency="usd"}
- Starter | $0 | Basic usage
- Pro | $12/mo | Teams and automation
:::`,
  },
})

Register it next to the generic pack:

const registry = createBlockRegistry().use(genericBlocks()).add(pricingTableBlock)

That's it — validateDocument now enforces the schema, toAuthoringGuide() includes the block, and any renderer can map pricing-table to an implementation.

The fields

  • name — lowercase kebab-case, unique per registry. Duplicates throw.
  • props — a zod schema for the open-line props. Defaults are applied during validation; violations become CB_PROPS_INVALID diagnostics with the zod path. Generated authoring guides document every prop — name, type with enum values spelled out, required/optional, default, and any .describe() text — so describing props pays off: your LLM agent reads those descriptions.
  • content — a content model (below) that parses the body into typed data.
  • childOnly — set true for blocks that only make sense inside a parent (like tab inside tabs).
  • interactive — a hint to renderers that the block needs client behavior.
  • authoringuseWhen / avoidWhen / example, consumed by the generated authoring guide. Keep the example valid: it's worth testing that every example passes your own registry.

Content models

Four helpers cover most blocks:

markdownBody({ minLength: 10 }) // free Markdown body
pipeRows({ columns: ['qty', 'name', 'note'], optionalColumns: 1 }) // pipe rows
listItems({ marker: 'ordered', minItems: 2 }) // bullet | ordered | signed
childBlocks({ allowed: ['tab'], minChildren: 2 }) // nested child blocks

Each helper reports diagnostics with the exact body line, so authors see content.md:14:1 instead of "somewhere in this block".

For unusual shapes, provide your own content model: any object with kind, describe(), and parse(node, report) works.

Rendering custom blocks

Renderers are adapter-side maps — block definitions never reference framework code:

// React
import { defineBlockComponent, defineBlockComponents } from '@contentbit/react'

const PricingTable = defineBlockComponent(pricingTableBlock, ({ node }) => {
  // Inferred: node.props.currency and node.data.rows need no casts.
  return <PricingTableView currency={node.props.currency} rows={node.data.rows} />
})

const components = defineBlockComponents([pricingTableBlock], {
  'pricing-table': PricingTable,
})

<ContentBlocks document={doc} components={components} />

defineBlockComponent carries the block name, Zod output props, and content model data across the renderer seam. defineBlockComponents also rejects misspelled block-name keys.

// Astro — a plain .astro component; props arrive validated, body via <slot />
<ContentBlocks document={doc} components={{ 'pricing-table': PricingTable }} />

On this page