Build pages with blocks
A blocks field stores an ordered page composition. Each item records a block type, a retained schema version, a stable key, and the fields declared by that version. Editors add and reorder blocks in the content editor. The Astro route maps each block type to a component.
Define the block types
Section titled “Define the block types”Define block types before the collection field that uses them. A seed preserves every numbered version and the active currentVersion pointer.
The following seed defines Hero and Feature grid blocks, then makes them available in a Pages layout field:
{ "$schema": "https://emdashcms.com/seed.schema.json", "version": "1", "blockTypes": [ { "slug": "hero", "label": "Hero", "description": "A heading, body text and an image", "icon": "sparkle", "category": "Layout", "currentVersion": 1, "versions": [ { "version": 1, "fields": [ { "slug": "heading", "label": "Heading", "type": "string", "required": true }, { "slug": "body", "label": "Body", "type": "portableText" }, { "slug": "image", "label": "Image", "type": "image" }, { "slug": "link_label", "label": "Link label", "type": "string" }, { "slug": "link_url", "label": "Link URL", "type": "url" } ] } ] }, { "slug": "feature_grid", "label": "Feature grid", "description": "A heading and a grid of features", "icon": "squares-four", "category": "Layout", "currentVersion": 1, "versions": [ { "version": 1, "fields": [ { "slug": "heading", "label": "Heading", "type": "string" }, { "slug": "items", "label": "Items", "type": "repeater", "validation": { "subFields": [ { "slug": "title", "label": "Title", "type": "string", "required": true }, { "slug": "description", "label": "Description", "type": "text" } ] } } ] } ] } ], "collections": [ { "slug": "pages", "label": "Pages", "fields": [ { "slug": "title", "label": "Title", "type": "string", "required": true }, { "slug": "layout", "label": "Layout", "type": "blocks", "validation": { "allowedTypes": ["hero", "feature_grid"], "maxItems": 20 } } ] } ]}The block picker lists block types in allowedTypes order, grouped by category, and shows each type’s description and icon. Set icon to a Phosphor icon name, such as sparkle or chart-bar; editors also see it on each block in the content editor. A block type without an icon, or with a name that doesn’t match a Phosphor icon, shows a generic block icon.
If you remove an allowed type later, EmDash moves it to the server-managed retiredTypes list. Existing blocks remain editable, but editors cannot add or duplicate that type.
Name fields for the editor layout
Section titled “Name fields for the editor layout”An open block in the content editor shows its content first: text, rich text, photos and lists, each under its label. Settings follow as rows with the label beside the value, then an Advanced row. The editor places each field by its type and slug, not its label, and shows every label as written. These conventions change where a field goes:
- Link fields. A
urlfield, or astringfield whose slug ends in_url,_linkor_href, gets a settings row. - Buttons. A
stringfield named{base}_label,{base}_textor{base}shares one row with a link field named{base}_url,{base}_linkor{base}_href. The row takes the text field’s label. In the Hero above,link_labelandlink_urlshare a row labelled “Link label”. - Captions. A
stringfield named{image}_captionsits under theimagefield named{image}, such asphoto_captionunderphoto. - Section ID. A field named
anchor_idgoes in the Advanced row. - Headline. The first
stringortextfield whose slug has the word headline, heading, title or statement is drawn larger, as the block’s headline. A field whose slug has the word label, eyebrow, kicker or tag is never the headline. Slugs split into words at underscores, sosection_headingcounts andsubheadlinedoesn’t. When no field matches, the block’s firststringortextfield is the headline unless its slug has one of those excluded words.
A repeater field inside a block takes its shape from its sub-fields:
- Type-in list. A list with exactly one sub-field, of type
stringorurl, shows each item as a bulleted line. Enter adds a line, and pasting several lines adds one item per line. A list of client names fits this shape. - Table. A list with up to three sub-fields, each of type
string,url,number,integer,selectorimage, and at most oneimage, shows a row per item and a column per sub-field. A list of stats with a value and a label fits this shape. - Expandable items. Every other list shows each item as a row that opens to edit its sub-fields. An open item lays out its sub-fields with the rules above, except that no sub-field is drawn as a headline or goes in an Advanced row, a photo gets a settings row, and
{image}_captionis an ordinary text field.
An item’s title is its first string sub-field that is neither a link field nor a button’s text, or else its first text sub-field. A closed item shows its title, followed by up to two of its other string values. When those are all empty, it shows the start of its first other text value instead. In the Feature grid above, items shows as expandable items titled by title. Adding a fourth sub-field, or a text sub-field, to a table’s block type turns the list into expandable items.
These rules are best-effort layout hints and can change in a later release. They affect only how the editor arranges the form, and a field that matches none of them gets a row of its own.
Create the Astro components
Section titled “Create the Astro components”Each component receives value, index, and blockKey. The value includes _type, _version, and _key, so a component can narrow retained versions when its block schema evolves.
The Hero component reads every displayed value from the stored block:
---import { sanitizeHref } from "emdash";import { Image, PortableText, type BlockComponentProps } from "emdash/ui";import type { PageLayoutBlock } from "../../../emdash-env";
type HeroBlock = Extract<PageLayoutBlock, { _type: "hero" }>;type Props = BlockComponentProps<HeroBlock>;
const { value } = Astro.props;---
<section class="hero"> <div> <h1>{value.heading}</h1> {value.body && <PortableText value={value.body} />} {value.link_url && <a href={sanitizeHref(value.link_url)}>{value.link_label}</a>} </div> {value.image && <Image image={value.image} />}</section>Create a component for each allowed type. The component controls markup and styling; the block value supplies content and media.
Render the composition
Section titled “Render the composition”Use defineBlockComponents to require one component for every _type in the generated field union. Pass that map to <Blocks> in the page route.
---import { decodeSlug, getEmDashEntry } from "emdash";import { Blocks, defineBlockComponents } from "emdash/ui";import type { PageLayoutBlock } from "../../../emdash-env";import FeatureGrid from "../../components/blocks/FeatureGrid.astro";import Hero from "../../components/blocks/Hero.astro";
const slug = decodeSlug(Astro.params.slug);if (!slug) return Astro.rewrite("/404");
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);if (!page) return Astro.rewrite("/404");Astro.cache.set(cacheHint);
const components = defineBlockComponents<PageLayoutBlock>({ hero: Hero, feature_grid: FeatureGrid,});---
<Blocks value={page.data.layout} components={components} /><Blocks> performs no content, schema, media, or network queries. It renders the supplied array in stored order. A block component can make an explicit application query when that component needs other data.
Handle a missing component
Section titled “Handle a missing component”During development, an unmapped type produces a visible placeholder and a console warning. The placeholder names the _type but does not print the stored block value.
In production, an unmapped type renders the fallback component when supplied. Otherwise it renders no output:
---import MissingBlock from "../../components/blocks/MissingBlock.astro";---
<Blocks value={page.data.layout} components={components} fallback={MissingBlock} />Ship renderer support before enabling or activating a block type used by production content.
Change a block schema
Section titled “Change a block schema”Compatible changes amend the active version. Adding an optional field, adding a default, or loosening validation keeps the same version number. Stored blocks receive defaults the next time they are written.
A breaking change creates an inactive version. Removing a field, changing a field type, adding a required field, or narrowing validation is breaking.
-
Create the breaking version through the schema API or MCP. Keep it inactive.
-
Update the renderer to handle both the retained version and the new version. Deploy the renderer.
-
Activate the new version. New blocks use it after activation.
-
Migrate stored blocks explicitly with
migrateBlocks: true. Preserve each block_keywhile changing_versionand its version-specific fields.
Old versions remain available for revisions, drafts, media tracking, and stored content. Block types and retained versions do not have a hard-delete operation.
Supported nested fields
Section titled “Supported nested fields”Block definitions support string, text, url, number, integer, boolean, datetime, select, multiSelect, portableText, image, file, and repeater.
References, JSON, slugs, nested blocks, custom widgets, physical indexes, uniqueness, and per-subfield localization are not supported inside a block definition. A blocks field itself cannot be required, unique, searchable, indexed, or assigned a custom field widget.
For exact field validation and stored value rules, see the blocks field reference. For seed conflict and export behavior, see Seed files.