Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Blocks

Flexible content field with multiple block types. Each block type has its own schema. Stored in a join table with JSON data.

Storage

Blocks fields use a dedicated join table: {collection}_{field}.

The join table has columns:

ColumnTypeDescription
idTEXT PRIMARY KEYNanoid for each row
parent_idTEXT NOT NULLForeign key to the parent document
_orderINTEGER NOT NULLSort order (0-indexed)
_block_typeTEXT NOT NULLBlock type identifier
dataTEXT NOT NULLJSON object containing the block’s field values

Unlike arrays (which have typed columns per sub-field), blocks use a single JSON data column because each block type can have a different schema.

Definition

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            fields = {
                crap.fields.text({ name = "heading", required = true }),
                crap.fields.text({ name = "subheading" }),
                crap.fields.text({ name = "image_url" }),
            },
        },
        {
            type = "richtext",
            label = "Rich Text",
            fields = {
                crap.fields.richtext({ name = "body" }),
            },
        },
        {
            type = "cta",
            label = "Call to Action",
            fields = {
                crap.fields.text({ name = "text", required = true }),
                crap.fields.text({ name = "url", required = true }),
                crap.fields.select({ name = "style", options = {
                    { label = "Primary", value = "primary" },
                    { label = "Secondary", value = "secondary" },
                }}),
            },
        },
    },
})

Layout Wrappers in Block Fields

Block sub-fields can be organized with Row, Collapsible, and Tabs layout wrappers. Since blocks store data as JSON, wrappers are transparent at the data layer — their children appear as flat keys in the JSON object.

blocks = {
    {
        type = "feature_card",
        label = "Feature Card",
        fields = {
            crap.fields.tabs({
                name = "card_tabs",
                tabs = {
                    {
                        label = "Content",
                        fields = {
                            crap.fields.text({ name = "heading", required = true }),
                            crap.fields.textarea({ name = "body" }),
                        },
                    },
                    {
                        label = "Style",
                        fields = {
                            crap.fields.select({ name = "variant", options = { ... } }),
                        },
                    },
                },
            }),
        },
    },
}

The JSON data contains heading, body, and variant as flat keys — the Tabs wrapper is invisible. Nesting is supported at arbitrary depth. See Layout Wrappers for details.

Block Definitions

Each block definition has:

PropertyTypeDescription
typestringRequired. Block type identifier.
labelstringDisplay label (defaults to type name).
label_fieldstringSub-field name to use as row label for this block type.
groupstringGroup name for organizing blocks in the picker dropdown.
image_urlstringImage URL for icon/thumbnail in the block picker.
fieldsFieldDefinition[]Fields within this block type.

API Representation

In API responses, blocks fields appear as a JSON array of objects, each with _block_type and the block’s field values:

{
  "content": [
    {
      "id": "abc123",
      "_block_type": "hero",
      "heading": "Welcome",
      "subheading": "To our site"
    },
    {
      "id": "def456",
      "_block_type": "richtext",
      "body": "<p>Some content...</p>"
    }
  ]
}

Writing Blocks Data

Via gRPC, pass an array of objects with _block_type:

{
  "content": [
    { "_block_type": "hero", "heading": "Welcome", "subheading": "To our site" },
    { "_block_type": "richtext", "body": "<p>Content here</p>" }
  ]
}

On write, all existing block rows for the parent are deleted and replaced. This is a full replacement, not a merge.

Row Labels

By default, block rows display the block type label and row index (e.g., “Hero Section 0”). You can customize this per block type with label_field, or across all block types with row_label.

Per-Block label_field

Set label_field on each block definition to a sub-field name. The value of that field is used as the row title, and updates live as you type.

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            label_field = "heading",
            fields = {
                crap.fields.text({ name = "heading", required = true }),
                crap.fields.text({ name = "subheading" }),
            },
        },
        {
            type = "image",
            label = "Image",
            label_field = "caption",
            fields = {
                crap.fields.upload({ name = "image", relationship = { collection = "media" } }),
                crap.fields.text({ name = "caption" }),
            },
        },
    },
})

Each block type can have a different label_field — hero blocks show the heading, image blocks show the caption.

row_label (Lua function)

For computed labels across all block types, set admin.row_label on the blocks field. The function receives the full row data (including _block_type) and returns a display string.

-- collections/posts.lua
crap.fields.blocks({
    name = "content",
    admin = {
        row_label = "labels.content_block_row",
    },
    blocks = { ... },
})
-- hooks/labels.lua
local M = {}

function M.content_block_row(row)
    if row._block_type == "hero" then
        return "Hero: " .. (row.heading or "Untitled")
    elseif row._block_type == "code" then
        local lang = row.language or ""
        if lang ~= "" then return "Code (" .. lang .. ")" end
        return "Code"
    end
    return nil -- fall back to per-block label_field or default
end

return M

Priority

  1. row_label Lua function (if set and returns a non-empty string)
  2. Per-block label_field on the BlockDefinition
  3. Field-level admin.label_field (shared across all block types)
  4. Default: block type label + row index (e.g., “Hero Section 0”)

Note: row_label is only evaluated server-side. Rows added via JavaScript in the browser fall back to label_field (live-updated) or the default until the form is saved and reloaded.

Row Limits (min_rows / max_rows)

Enforce minimum and maximum block counts. These are validation constraints, not just UI hints.

crap.fields.blocks({
    name = "content",
    min_rows = 1,
    max_rows = 20,
    blocks = { ... },
})
  • min_rows: Minimum number of blocks. Validated on create/update (skipped for draft saves).
  • max_rows: Maximum number of blocks. Validated on create/update. The admin UI disables the “Add Block” button when the limit is reached.

Default Collapsed State (collapsed)

Existing block rows render collapsed by default on page load (admin.collapsed = true). Set admin.collapsed = false to start rows expanded. New blocks added via the UI are always expanded.

crap.fields.blocks({
    name = "content",
    admin = {
        collapsed = false, -- start rows expanded (default is true)
    },
    blocks = { ... },
})

Custom Labels (labels)

Customize the “Add Block” button text with singular/plural labels.

crap.fields.blocks({
    name = "content",
    admin = {
        labels = { singular = "Section", plural = "Sections" },
    },
    blocks = { ... },
})

With this config, the add button reads “Add Section” instead of “Add Block”.

Block Groups

Organize blocks into groups in the picker dropdown using <optgroup> elements. Ungrouped blocks appear at the top.

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            group = "Layout",
            fields = { ... },
        },
        {
            type = "columns",
            label = "Columns",
            group = "Layout",
            fields = { ... },
        },
        {
            type = "richtext",
            label = "Rich Text",
            group = "Content",
            fields = { ... },
        },
        {
            type = "divider",
            label = "Divider",
            -- No group: appears at the top of the dropdown
            fields = {},
        },
    },
})

Card Picker

By default, blocks use a dropdown select to choose the block type. Set admin.picker = "card" to use a visual card grid instead. This is useful when you have several block types and want a more visual picker.

crap.fields.blocks({
    name = "content",
    admin = {
        picker = "card",
    },
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            fields = { ... },
        },
        {
            type = "richtext",
            label = "Rich Text",
            fields = { ... },
        },
    },
})

Each card shows the block type label and a generic icon. To display custom icons or thumbnails, set image_url on individual block definitions:

blocks = {
    {
        type = "hero",
        label = "Hero Section",
        image_url = "/static/blocks/hero.svg",
        fields = { ... },
    },
    {
        type = "richtext",
        label = "Rich Text",
        image_url = "/static/blocks/text.svg",
        fields = { ... },
    },
}

Blocks without an image_url show a generic widget icon. Both group and image_url can be combined with the card picker.

Admin Rendering

Renders as a repeatable fieldset with:

  • Drag handle for drag-and-drop reordering
  • Row count badge showing the number of blocks
  • Toggle collapse/expand all button
  • Block type selector dropdown with “Add Block” button (or custom label)
  • Each row shows the block type label (or custom label), move up/down, duplicate, and remove buttons
  • “No items yet” empty state when no blocks exist
  • Block-specific fields rendered within each row
  • Add button disabled when max_rows is reached