Byline CMS
  • Home
  • Docs
  • About
Byline CMS
View on GitHub
  • Search & Document Extraction
  • Getting Started
    • Overview
    • CLI
    • Development environment and example application
    • Configuration
    • Upgrading from 3.21 to 4.x
    • Upgrading from 4.11 to 4.12
  • Why Byline
    • Overview
    • Mission & Vision
    • Content Management in the Time of AI
    • Byline for Collections
  • Key Architectural Decisions
    • Overview
    • Core Document Storage
    • Core Composition
    • Transactions
    • Path Grammar
    • Deployment Topologies
  • Collections
    • Overview
    • Fields
    • Blocks
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Overview
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
  • Search
    • Overview
    • Configure search
    • Indexing and reindexing
    • Search API
    • Search provider contract
    • Portable multilingual search analysis
    • PostgreSQL and MySQL search providers
    • Attachment extraction for search
    • Native search engines and backend portability
    • Semantic discovery and institutional standards
  • Auth & Security
    • Overview
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • Overview
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • Overview
    • UI Kit (@byline/ui)
    • Admin-config registration
  • API Reference
    • Overview
    • Configuration API
    • Collections API
    • Fields API
    • Client SDK API
  • Testing
  • Home

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

Block / BlocksField interfaces

packages/core/src/@types/field-types.ts

defineBlock, BlockFieldData, BlockData, BlocksUnion

packages/core/src/@types/collection-types.ts

BlockAdminConfig, defineBlockAdmin

packages/core/src/@types/admin-types.ts

Registration

AdminConfig.blockAdmin (packages/core/src/@types/site-config.ts)

Boot validation

validateBlockAdminConfigs in packages/core/src/config/validate-admin-configs.ts

Admin rendering

packages/admin/src/fields/blocks/blocks-field.tsx: each block instance renders through a synthesized GroupField → FieldRenderer per child

Storage

flat EAV rows; item identity (_id, _type) in store_meta (UUIDv7)

Type generation

packages/core/src/codegen/index.ts: structural dedup of block contracts across collections; emits <X>BlockData (+ AllLocales) discriminated on _type, inside declare module '@byline/generated-types'

Frontend registry

apps/webapp/src/ui/byline/render-blocks.tsx: exhaustive switch (block._type) with a never guard

Reference blocks

apps/webapp/byline/blocks/{richtext,photo,code,quote,faq}-block.ts (+ .admin.ts where tailored; faq-block.admin.ts is the dotted schema-path reference)


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>
>
}
): BlockAdminConfig

FieldAdminConfig 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.ts
blockAdmin: [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 docs and pages as one contract; a blockType-keyed registry gives it one admin identity to match.
  • Zero path-threading: BlocksField resolves by item._type wherever 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 by packages/admin/src/fields/group/group-field.tsx), (2) the admin layout group primitive (groups: in CollectionAdminConfig: pure visual clustering of top-level fields; rendered by the presentation layer), and (3) the synthesized GroupField inside BlocksField described above. The fieldAdmin prop lands on the structural widget because blocks render through it; plain schema group and array fields receive the same prop from the collection admin config's fields{} map (sliced per level by FormRenderer → 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 EditorConfig from structuredClone(defaultEditorConfig) (imported from @byline/richtext-lexical/server, the data-only subpath) and bake it into the block field's editorConfig. JSON-safe: toolbar toggles, placeholder, markdown behavior.
  • Extension half (admin) — build the editor component inline with lexicalEditor((c) => …removals…) from @byline/richtext-lexical/config in the block's .admin.ts, registered via defineBlockAdmin. 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 blocks field renders as Array<A | B | …> discriminated on _type, which is what makes the frontend registry's exhaustive switch + never guard work: register a block in any schema, regenerate, and the typecheck fails until a renderer exists.
  • Admin config, upload config, and editorConfig are not part of the structural contract: per-collection block-factory instances (see below) share one generated type as long as blockType and 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

defineBlock + block data types

packages/core/src/@types/collection-types.ts

defineBlockAdmin + BlockAdminConfig

packages/core/src/@types/admin-types.ts

blockAdmin registration slot

packages/core/src/@types/site-config.ts (AdminConfig)

Boot validation + tests

packages/core/src/config/validate-admin-configs{.ts,.test.node.ts}

BlocksField (picker, D&D, registry resolution)

packages/admin/src/fields/blocks/blocks-field.tsx

GroupField fieldAdmin threading

packages/admin/src/fields/group/group-field.tsx

Codegen (dedup, _type unions)

packages/core/src/codegen/index.ts

Reference block schemas

apps/webapp/byline/blocks/*.ts

Reference block admin configs

apps/webapp/byline/blocks/*.admin.ts

Reference frontend registry + components

apps/webapp/src/ui/byline/render-blocks.tsx, src/ui/byline/blocks/*

PreviousFields
NextRelationships

On this Page

  • Overview
  • Key machinery
  • Per-block admin config (defineBlockAdmin)
  • API (@byline/core)
  • Registration — blockType-keyed registry
  • How the override reaches the widget
  • Arrays inside blocks — structural editing and sorting
  • What block admin config never touches
  • Tailoring editors per block
  • Blocks and type generation
  • Blocks with inline uploads (upload.location)
  • Code map
Byline CMS

Building the future of content management, one commit at a time.

Project

  • Documentation
  • Roadmap
  • Contributing
  • Releases

Community

  • GitHub Discussions
  • Blog
  • Newsletter

Legal

  • Privacy Policy
  • Terms of Use
  • Cookies

© 2026 Infonomic Company Limited and contributors. Open source and built with ❤️ by the community.