Adding a block type

The exact steps to add a new page-builder block, and the parallel pattern for form field kinds.

Every block type is looked up by a single typestring, both by the public page renderer and the dashboard builder canvas — adding a new one never requires either of those to change. The steps below are numbered in the order you’d actually do them.

Steps

  1. Add the config type and a union member in src/lib/blocks/types.ts. Every other block config is defined the same way — see CustomHtmlConfig at src/lib/blocks/types.ts:242 — and the BlockInstance discriminated union that every block adds one arm to starts at src/lib/blocks/types.ts:292.
  2. Create src/lib/blocks/blocks/<name>.tsx exporting a default config, an Edit component, and a Render component. Use an existing block as the template — src/lib/blocks/blocks/custom-html.tsx exports customHtmlDefaultConfig at src/lib/blocks/blocks/custom-html.tsx:9, CustomHtmlEdit at src/lib/blocks/blocks/custom-html.tsx:16, and CustomHtmlRender at src/lib/blocks/blocks/custom-html.tsx:91.
  3. Add one entry to BLOCK_REGISTRY in src/lib/blocks/registry.tsx (the object starts at src/lib/blocks/registry.tsx:78; the custom-html entry at src/lib/blocks/registry.tsx:150 is the shape to copy: type label, default config, Edit, Render).
  4. Add the type string to BLOCK_TYPES in src/lib/schemas/page-schema.ts:12— this is the Zod enum that validates every stored block’s type field; a block type missing here gets silently dropped by parsePageSchema the moment a page with it is loaded.

That’s the whole extension point. Import it and register it once each:

export type BlockDefinition<C> = {
  type: BlockType;
  label: string;
  defaultConfig: C;
  Edit: ComponentType<{
    config: C;
    onChange: (next: C) => void;
    childBlocks?: BlockInstance[];
    renderChildList?: () => ReactNode;
    event?: EventRecord;
    onEventFieldsChange?: (patch: Partial<EventRecord>) => void;
    availableForms?: FormRecord[];
  }>;
  Render: ComponentType<{
    config: C;
    ctx: PageRenderContext;
    renderedChildren?: ReactNode[];
  }>;
};

childBlocks/renderChildListare only read by the container block’s Edit; event/onEventFieldsChangeonly by blocks (like hero) that edit the event’s own fields directly; availableFormsonly by the form block’s “which form?” dropdown. A new block’s Edit simply ignores whichever of these it doesn’t need.

The parallel pattern for form fields

The generic Forms system (src/lib/forms/) is explicitly modeled on the same shape, applied to form field kinds instead of page blocks: a FieldTypeDefinition type (src/lib/forms/registry.tsx:45) pairs a config type with an Edit/Input component pair and one FieldValidator subclass, all looked up from a single FIELD_TYPE_REGISTRY table (src/lib/forms/registry.tsx:68). Adding a new field kind means: a config type in src/lib/forms/types.ts, a validator extending FieldValidator (the abstract base at src/lib/forms/validators/base.ts:10 implements the shared required/empty short-circuit once — a subclass only implements its own isEmpty and validateValue), an Edit+Input pair under src/lib/forms/fields/*.tsx, and one entry in FIELD_TYPE_REGISTRY plus the kind string in FIELD_KINDS (src/lib/forms/registry.tsx:173).

Good to know

Both registries exist so their respective renderer/editor never needs a type-specific branch anywhere else in the codebase — every consumer just looks the type up by key.