Blocks
Companions:
- Fields — field types (including
code), the schema-vs-admin split, conditional visibility, virtual fields. - Rich Text — the Lexical adapter,
lexicalEditor(), and per-field editor overrides. - Relationships — relation fields and populate (block fields participate identically).
- File / Media Uploads — inline upload fields and
upload.location.
Overview
A block is a reusable, typed unit of structured content that editors compose inside a type: 'blocks' field: a quote, a photo with a caption, a code sample. You reach for a block when a field needs to hold a mix-and-match sequence of these units rather than one fixed shape.
Read this document when you are defining a new block, tailoring one block's editor, or wiring blocks into a collection. Like collections, blocks live on two sides of the schema/admin split:
// apps/webapp/byline/blocks/quote-block.ts (schema — React-free, tsx-loadable)export const QuoteBlock = defineBlock({ blockType: 'quoteBlock', label: 'Quote Block', fields: [ /* ordinary Field[] — text, select, relation, richText, code, … */ ],})
// apps/webapp/byline/blocks/quote-block.admin.ts (admin — may carry React)export const QuoteBlockAdmin = defineBlockAdmin(QuoteBlock, { fields: { quoteText: { editor: PlainLexicalEditor } },})A collection consumes blocks through a normal field:
{ name: 'content', type: 'blocks', optional: true, blocks: [RichTextBlock, PhotoBlock, CodeBlock, QuoteBlock] }Stored data is a flat array of { _id, _type, ...fields } items: _type discriminates the union, _id (UUIDv7, held in store_meta) is the stable item identity that drag-reordering and the patch system key on.
Everything that works for collection fields works for block child fields: conditional visibility (condition(data, siblingData)), virtual fields, field-level hooks and validation, localization per child field, relations (with populate), and inline upload fields.
Key machinery
Concern | Where |
|
|
|
|
|
|
Registration |
|
Boot validation |
|
Admin rendering |
|
Storage | flat EAV rows; item identity ( |
Type generation |
|
Frontend registry |
|
Reference blocks |
|
Per-block admin config (defineBlockAdmin)
Blocks keep the collections' contract: schema files stay React-free; admin config lives separately and may carry React (editor components, slot components).
API (@byline/core)
export interface BlockAdminConfig { blockType: string /** Per-field overrides, keyed by index-free schema paths relative to the block root. */ fields?: Record<string, FieldAdminConfig>}
export function defineBlockAdmin<B extends Block>( block: B, config: { fields?: Partial< Record<Extract<keyof BlockFieldData<B>, string> | (string & {}), FieldAdminConfig> > }): BlockAdminConfigFieldAdminConfig is reused unchanged (components slots + editor override). Keys are schema paths relative to the block root: a top-level field name (quoteText, which the type parameter autocompletes) or a dotted, index-free path through the block's group / array structure (faq.answer; see Schema paths vs instance paths). A schema path addresses a field declaration, so one entry applies to that field in every array item. Paths never traverse a nested blocks field: an inner block resolves its own registry entry wherever it renders. The collection-level fields{} map speaks the same notation for non-block nesting.
Registration — blockType-keyed registry
// byline/admin.config.tsblockAdmin: [QuoteBlockAdmin, PhotoBlockAdmin],Registered site-wide on AdminConfig.blockAdmin and applied wherever the block renders: any collection, any nesting. Rationale for a registry over per-collection nesting:
- Blocks are cross-collection deduped units: codegen already treats the same block in
docsandpagesas one contract; a blockType-keyed registry gives it one admin identity to match. - Zero path-threading:
BlocksFieldresolves byitem._typewherever it renders (inside tabs, groups, drag-reordered, freshly inserted). - Per-collection variance escape hatch: define a second block with a different
blockType; a per-collection override layer can be added later without breaking this API.
Boot validation (validateBlockAdminConfigs, run by defineAdminConfig) walks all collections' fields collecting blockType → Block declaration sites and fails on unknown blockTypes, duplicate entries, or fields keys that don't resolve as schema paths within the block (an index-carrying key, a path through a value field or a nested blocks field, or a leaf that isn't declared, checked against the union of declaration sites when the same blockType appears in several collections).
How the override reaches the widget
BlocksField has no child-rendering machinery of its own: each block instance renders by synthesizing a structural-group field object ({ type: 'group', name: blockType, fields: block.fields }) and handing it to GroupField, which loops the children through FieldRenderer. The admin config rides that hand-off: BlocksField resolves the registry by item._type and passes the entry's fields map as GroupField's fieldAdmin prop. Each structural widget then consumes the map one level at a time: a child's exact-name entry becomes its components/editor props, and its descendant entries are re-keyed with the prefix stripped (sliceFieldAdmin, packages/admin/src/fields/field-admin.ts) and threaded on through FieldRenderer into the child's own GroupField/ArrayField. That is how a dotted key like faq.answer walks the widget tree to the right leaf, where the existing resolution (editor ?? getAdminConfig().fields.richText.editor) lets the per-block override beat the site-wide default.
The three "group" concepts. Byline has (1) the structural group field (type: 'group'in a schema: shapes stored data; rendered bypackages/admin/src/fields/group/group-field.tsx), (2) the admin layout group primitive (groups:inCollectionAdminConfig: pure visual clustering of top-level fields; rendered by the presentation layer), and (3) the synthesized GroupField inside BlocksField described above. ThefieldAdminprop lands on the structural widget because blocks render through it; plain schemagroupandarrayfields receive the same prop from the collection admin config'sfields{}map (sliced per level byFormRenderer→FieldRenderer), so dotted keys work identically inside and outside blocks.
Arrays inside blocks — structural editing and sorting
An array field renders its title, add-row, and per-item add-below / remove controls at every nesting depth: structural editing never depends on sortability. Drag-reordering is governed by a disableSorting flag threaded through the structural widgets: BlocksField passes false on its synthesized group, so arrays anywhere inside a block (directly or via a group) are fully drag-sortable, safe because each DraggableSortable is an independent dnd-kit DndContext with grip-scoped listeners, so an inner array's drags can't leak into the block-level context. Arrays inside plain top-level groups and arrays nested inside another array's items keep the conservative drag-free default (add/remove still available; grip-less items render through StaticItem, the non-dnd sibling of SortableItem). Browser-level coverage: apps/webapp/e2e/array-in-block.spec.ts (add → fill → save → keyboard drag-reorder → remove, round-tripped through reloads against the reference FAQBlock).
What block admin config never touches
defineBlock / BlockData / serialization / codegen / storage / zod: admin config never enters the schema graph. Adding or changing a block's admin entry changes no generated type and no stored byte.
Tailoring editors per block
The worked pattern for giving one block's richText field its own editor (reference implementations: apps/webapp/byline/blocks/photo-block{.ts,.admin.ts} and quote-block{.ts,.admin.ts}):
- Settings half (schema) — build an
EditorConfigfromstructuredClone(defaultEditorConfig)(imported from@byline/richtext-lexical/server, the data-only subpath) and bake it into the block field'seditorConfig. JSON-safe: toolbar toggles, placeholder, markdown behavior. - Extension half (admin) — build the editor component inline with
lexicalEditor((c) => …removals…)from@byline/richtext-lexical/configin the block's.admin.ts, registered viadefineBlockAdmin. React-safe: which node extensions (Insert-menu items, links, floating UI) survive.
The two reference blocks deliberately differ: PhotoBlock's caption keeps Link/AutoLink (photo credits carry links); QuoteBlock's quotation removes them. Both replace the site-wide AI-enabled editor for that one field while every other richText field keeps it.
FAQBlock (faq-block{.ts,.admin.ts}) extends the same pattern to a nested field: its answer editor's settings half is baked into the answer field inside the block's faq array, and the extension half is registered with the dotted schema-path key faq.answer, one entry that applies to the answer field of every FAQ item.
Blocks and type generation
Blocks are fully covered by emitCollectionTypes (see the generate script in apps/webapp/byline/scripts/generate-types.ts):
- Block contracts are structurally deduped across collections: the same block object (or two blocks with identical
blockType+ field signature) emits one exported alias (QuoteBlockData+QuoteBlockDataAllLocales). - A
blocksfield renders asArray<A | B | …>discriminated on_type, which is what makes the frontend registry's exhaustiveswitch+neverguard work: register a block in any schema, regenerate, and the typecheck fails until a renderer exists. - Admin config, upload config, and
editorConfigare not part of the structural contract: per-collection block-factory instances (see below) share one generated type as long asblockTypeand field shapes match.
Blocks with inline uploads (upload.location)
Blocks may carry their own image/file fields instead of relating to a media collection: right when the asset is document-owned (uploaded in place, never reused). Because the same block is typically consumed by several collections, write upload-carrying blocks as factories so each collection instantiates its own storage scope:
export const attachmentsBlock = (opts: { location: string }) => defineBlock({ blockType: 'attachmentsBlock', fields: [ { name: 'attachments', type: 'array', fields: [{ name: 'file', type: 'file', upload: { mimeTypes: ['application/pdf'], location: opts.location }, }], }, ], })
// news schema: attachmentsBlock({ location: 'news/attachments' })// pages schema: attachmentsBlock({ location: 'pages/attachments' })upload.location is boot-validated plain data; see File / Media Uploads → Scope a field's storage location. Factory instances dedupe to one generated block type (upload config is not part of the codegen contract). Keep the field structure identical across instances: structural drift forks the contract.
For library assets (reused, curated, own lifecycle) prefer a relation to a media collection instead: the reference PhotoBlock relates to media.
Code map
Concern | Location |
|
|
|
|
|
|
Boot validation + tests |
|
BlocksField (picker, D&D, registry resolution) |
|
GroupField |
|
Codegen (dedup, |
|
Reference block schemas |
|
Reference block admin configs |
|
Reference frontend registry + components |
|