Fields API
Companions:
- RICHTEXT.md — the Lexical adapter and how
lexicalEditor()/ per-field editor overrides plug in. - COLLECTIONS.md — collection-level admin (columns, layout, preview URL, custom list views).
- ARCHITECTURE.md — the schema / admin split (Django-style model vs ModelAdmin) at the framework level.
Overview
A field in Byline lives on two sides of a deliberate split. The schema describes what the field is — name, type, validation, defaults, schema-level adapter config. The admin describes how the field renders in the dashboard — slot-component overrides for label, input, help text, adornments, and the per-field richtext editor swap. This doc is the working reference for both sides: how the split works, how to write helpers for each side, and how field-level component slots compose.
The split mirrors Django's Model / ModelAdmin. The same field name appears on both sides — the schema's fields[] array declares the data; the admin's fields{} map (keyed by schema field name) attaches presentation.
fields: [ fields: { { name: 'title', type: 'text' }, title: aiTextFieldAdmin(),] }schema (array, data) admin (map, keyed by schema name)Quick reference
Each entry is the minimal shape for one task. The "Edit" line tells you which file you actually change; the link at the end of each entry points at the deeper architecture section.
1. Drop in a schema-side helper
Use a pre-built field definition from apps/webapp/byline/fields/. Schema-side helpers are pure data — drop them in the schema's fields[] array.
Edit: apps/webapp/byline/collections/<name>/schema.ts
import { publishedOnField } from '../../fields/published-on-field.js'
export const News = defineCollection({ path: 'news', fields: [ { name: 'title', type: 'text', localized: true }, { name: 'content', type: 'richText' }, publishedOnField, ],})2. Drop in an admin-side helper
Use a pre-built FieldAdminConfig from apps/webapp/byline/fields/. Admin-side helpers can include React — drop them in the admin's fields{} map, keyed by the schema field's name.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import { aiTextFieldAdmin } from '../../fields/ai-text.js'import { aiRichTextAdmin } from '../../fields/lexical-richtext-ai.js'
export const NewsAdmin = defineAdmin(News, { fields: { title: aiTextFieldAdmin(), content: aiRichTextAdmin(), },})3. Replace the entire input (Field slot)
When the built-in widget isn't right, swap the entire input via components.Field. The component receives FieldInputSlotProps and must call onChange to commit changes back to the form store.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import type { FieldInputSlotProps } from '@byline/core'
function ColorSwatchInput({ value, onChange }: FieldInputSlotProps) { return <input type="color" value={value ?? '#000000'} onChange={(e) => onChange(e.target.value)} />}
export const ProductAdmin = defineAdmin(Product, { fields: { accentColor: { components: { Field: ColorSwatchInput } }, },})4. Customise the label
Replace the default <Label> for one field via components.Label. Useful for adding an icon, a status pill, or a toggle next to the label.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import type { FieldLabelSlotProps } from '@byline/core'
function RequiredLabel({ label, required }: FieldLabelSlotProps) { return <span>{label}{required && <em className="text-red-500"> *</em>}</span>}
export const NewsAdmin = defineAdmin(News, { fields: { title: { components: { Label: RequiredLabel } }, },})5. Replace the help text
Replace the default help-text line via components.HelpText. Reactive to form state — the slot can read live field values to render things like character counts.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import type { FieldHelpTextSlotProps } from '@byline/core'import { useFieldValue } from '@byline/admin/react'
function CharacterCount({ path, helpText }: FieldHelpTextSlotProps) { const value = useFieldValue<string>(path) ?? '' return <span>{helpText && `${helpText} — `}{value.length} chars</span>}
export const NewsAdmin = defineAdmin(News, { fields: { summary: { components: { HelpText: CharacterCount } }, },})6. Add a before/after adornment
beforeField renders between the label and the input; afterField renders between the input and the help text. The aiTextFieldAdmin helper uses afterField to mount the AI panel.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import type { FieldAdornmentSlotProps } from '@byline/core'
function Hint({ name }: FieldAdornmentSlotProps) { return <p className="text-xs text-gray-500">Editable in any locale; defaults from primary locale.</p>}
fields: { body: { components: { afterField: Hint } },}7. Per-field richtext editor override
For type: 'richText' fields only — swap the editor component itself (not just its settings). Lives on the admin side because it carries a React reference. Site-wide override is in admin.config.ts; per-field override goes here.
Edit: apps/webapp/byline/collections/<name>/admin.tsx
import { aiRichTextAdmin } from '../../fields/lexical-richtext-ai.js'
fields: { content: aiRichTextAdmin(), // sets `editor: LexicalRichTextAi` for this field}8. Write a new schema-side helper
A factory that returns a typed schema field. Pure data, tsx-loadable.
Edit: apps/webapp/byline/fields/<my-helper>.ts
import type { TextField } from '@byline/core'
type Options = Partial<Omit<TextField, 'type'>>
export function taglineField(options: Options = {}): TextField { return { name: 'tagline', label: 'Tagline', ...options, type: 'text', validation: { maxLength: 80, ...options.validation }, }}9. Write a new admin-side helper
A factory that returns a FieldAdminConfig. React is welcome here.
Edit: apps/webapp/byline/fields/<my-helper>.tsx
import type { FieldAdminConfig, FieldHelpTextSlotProps } from '@byline/core'import { useFieldValue } from '@byline/admin/react'
function CharacterCount({ path, helpText }: FieldHelpTextSlotProps) { const value = useFieldValue<string>(path) ?? '' return <span>{helpText ? `${helpText} — ` : ''}{value.length} chars</span>}
export function characterCountAdmin(): FieldAdminConfig { return { components: { HelpText: CharacterCount } }}10. Mix schema preset + admin override on one field
The two layers stack cleanly. A schema-side helper bakes data into editorConfig; an admin-side helper attaches React components. The field-renderer resolves both at render time.
Edit: apps/webapp/byline/collections/<name>/schema.ts and apps/webapp/byline/collections/<name>/admin.tsx
// schema.ts — compact toolbar baked into editorConfig (data)fields: [ lexicalRichTextCompact({ name: 'caption', label: 'Caption' }),]// admin.tsx — AI editor component override (React)fields: { caption: aiRichTextAdmin(),}Architecture
The schema / admin split
A field lives in two places at once:
- Schema (
collections/<name>/schema.ts) — aCollectionDefinitionreturned bydefineCollection. Pure data: field names, types, validation, defaults, schema-level adapter config (editorConfig,embedRelationsOnSave,localized, …). Must be tsx-loadable — the server bootstrap inapps/webapp/byline/server.config.tsimports schemas directly so seeds and migrations can run outside Vite. No React. No CSS modules. No browser-only globals. - Admin (
collections/<name>/admin.tsx) — aCollectionAdminConfigreturned bydefineAdmin. UI overrides: per-field slot components, the per-field editor swap, columns, layout, preview URL. React is allowed here. Pulled in byadmin.config.ts, which is side-effect-imported from__root.tsxso the registration covers both SSR and client module graphs.
The schema declares what the field is; the admin declares how it renders. The two cooperate at render time — schema declares richText and the admin attaches the editor component — and never collide because they target different layers of the pipeline.
Why the split is strict. The server-side bootstrap loads collection schemas under raw tsx for seeds, migrations, and ESM imports outside the Vite graph. The moment a schema file (or any of its imports, transitively) reaches React, CSS modules, or a Lexical runtime, that bootstrap breaks. The split forces React-bearing code onto the admin side, where it stays inside the Vite-managed admin module graph.
Schema-side helpers
A schema-side helper is a factory that returns a typed field definition. The result drops into the schema's fields[] array.
Rules of thumb.
- Return a typed schema field (
TextField,RichTextField,GroupField, …). Type the return value so callers get autocomplete andFieldData<typeof helper>resolves correctly. - Accept
Options = Partial<Omit<TheFieldType, 'type'>>so callers can override anything except the discriminant. - Keep imports data-only —
@byline/coretypes,defineField,defineCollection, the project's i18n locale list. When unsure, ask "will this file load under rawtsx?"
What's forbidden.
- React component references on schema fields.
- Imports from
@byline/admin/react,@byline/ui/react,@byline/richtext-lexical(the root barrel),@byline/ai/plugins/*, or any package whose evaluation pulls CSS modules or a Lexical runtime.
Data-only subpaths. Some packages publish a separate entry point for the schema-relevant data — @byline/richtext-lexical/server re-exports defaultEditorConfig precisely so schema-side helpers can use it without dragging the React entry along. Import from those subpaths when they exist.
Two patterns:
// Plain value — define once, drop in many collections.export const publishedOnField = defineField({ name: 'publishedOn', label: 'Published On', type: 'datetime', mode: 'datetime',})// Factory — accept caller overrides.export function lexicalRichTextCompact(options: Options = {}): RichTextField { const { configure, ...rest } = options const base = applyCompactPreset(structuredClone(defaultEditorConfig)) const editorConfig = configure ? configure(base) : base return { name: 'richText', label: 'RichText', ...rest, type: 'richText', editorConfig }}Admin-side helpers
An admin-side helper is a factory that returns a FieldAdminConfig. The result drops into the admin's fields{} map, keyed by the schema field's name.
// packages/core/src/@types/admin-types.ts (excerpt)export interface FieldAdminConfig { components?: FieldComponentSlots editor?: RichTextEditorComponent // only meaningful on richText fields}Rules of thumb.
- Return
FieldAdminConfig. Don't widen the return type — callers should get the same shapedefineAdminexpects. - React, hooks, CSS modules — all fine. This file is only evaluated in the admin module graph.
- Slot components can use the form-context hooks (
useFieldValue,useFieldError,useFormContext) — no need to plumb props in from the caller.
Worked example — the AI text helper. aiTextFieldAdmin() attaches two slot components: a Label replacement that adds an AI toggle button, and an afterField adornment that mounts the AI panel. Pairs with a plain { type: 'text' } entry on the schema side.
// apps/webapp/byline/fields/ai-text.tsimport { AiFieldLabel } from './ai-widgets/ai-field-label.js'import { AiFieldPanel } from './ai-widgets/ai-field-panel.js'
export function aiTextFieldAdmin( options: { components?: FieldComponentSlots } = {}): FieldAdminConfig { const { components: extra } = options return { components: { Label: AiFieldLabel, afterField: AiFieldPanel, ...extra, }, }}Field component slots
FieldComponentSlots is the per-field surface for swapping or augmenting the default rendering. Every value field (anything that isn't array, blocks, or group) accepts the same five slots.
Slot | Effect | Props |
| Replaces the default |
|
| Replaces the default help-text line. |
|
| Replaces the entire input widget. |
|
| Adornment between label and input. |
|
| Adornment between input and help text. |
|
Slot prop shapes. Every slot prop interface extends FieldSlotBaseProps, which carries the field's name, path, value, id (HTML), and the underlying field definition. The two replacement slots add specifics:
// FieldInputSlotProps (Field replacement){ onChange: (value: any) => void // call this to write a new value defaultValue?: any placeholder?: string // …plus the base props (name, path, value, id, field, etc.)}
// FieldLabelSlotProps{ label?: string; required?: boolean; /* + base */ }
// FieldHelpTextSlotProps{ helpText?: string; /* + base */ }
// FieldAdornmentSlotProps{ /* base only */ }Two important behaviours:
- Field replaces — not augments. When a
Fieldslot is provided, the default input is not rendered. The slot is responsible for callingonChangeto commit changes back into the form store (the form hook pipeline runs from there). - Form-context hooks work everywhere. Slot components can call
useFieldValue(path),useFieldError(path),useFormContext()etc. directly — there's no need to plumb live data in through props. This is what makes theCharacterCountHelpText example one short component.
Per-field richtext editor
For type: 'richText' fields only, FieldAdminConfig.editor swaps the entire editor component — not just its settings. Use it to opt one specific field into an alternate editor (e.g. an AI-enabled wrapper around the default Lexical field) without changing the site-wide registration.
// admin.tsxfields: { content: { editor: LexicalRichTextAi },}Lives on the admin side because it carries a React component reference, and schemas must stay tsx-loadable. Per-field editor takes precedence over the globally registered ClientConfig.fields.richText.editor. Ignored on non-richText fields.
For settings differences only (placeholder, toolbar toggles, the inline-image upload collection), use a schema-side preset like lexicalRichTextCompact instead — that data is JSON-safe and rides along in RichTextField.editorConfig. See RICHTEXT.md for the full editor configuration story.
Mixing both layers
Because the helpers live at different layers, they stack freely on the same field.
// schema.ts — compact toolbar baked into editorConfig (data)fields: [ lexicalRichTextCompact({ name: 'caption', label: 'Caption' }),]
// admin.tsx — AI-enabled editor component override (React)fields: { caption: aiRichTextAdmin(),}At render time the field-renderer resolves the editor component admin-side first (the AI wrapper wins over the global registration), and that component reads field.editorConfig from the schema (the compact preset). The result is an AI-enabled editor running the compact toolbar — no special wiring required. The same applies across helper kinds — a schema-side publishedOnField() and a future publishedOnAdmin() would coexist the same way.
Common pitfalls
Putting a React component reference on a schema field
The trap: a schema-side factory that bakes a React component into the returned schema (e.g. an editor: MyEditor assignment on a RichTextField). The factory's output contains a runtime reference to React, so the chain schema.ts → server.config.ts now imports React and Lexical. Seeds via tsx start failing on CSS module imports.
If a field needs a React swap, put it on the admin side via FieldAdminConfig.editor (or components.Field for non-rich-text fields). Never on the schema.
Importing from a React-y barrel inside a schema-side helper
A subtler version of the same trap. The factory's output is fine (data only), but its import statement reaches into a barrel that re-exports React components, triggering their evaluation when the schema module loads. Example: import { defaultEditorConfig } from '@byline/richtext-lexical' — defaultEditorConfig is data, but the root barrel re-exports RichTextField and friends, which loads CSS.
The fix is always the same: find or create a data-only subpath of the package (@byline/richtext-lexical/server re-exports the schema-relevant data here) and import from there.
Using lexicalRichTextCompact to enable AI
lexicalRichTextCompact is schema-side; it customises editorConfig (data). It cannot swap the editor component. Use aiRichTextAdmin() admin-side for that, or — for site-wide AI — register LexicalRichTextAi as ClientConfig.fields.richText.editor in admin.config.ts.
Putting AI text-field slots in the schema
aiTextFieldAdmin() / aiTextAreaFieldAdmin() attach Label and afterField slots — both React. They go in admin.tsx, not schema.ts. The schema entry stays a plain { name, type: 'text' }.
Code map
Concern | Location |
|
|
|
|
|
|
|
|
Field-renderer dispatch (resolves slots + per-field editor) |
|
Form-renderer (reads |
|
Existing schema-side helpers |
|
Existing admin-side helpers |
|
AI widgets used by admin-side helpers |
|
Reference admin config (admin-side wiring) |
|
Reference server config (schema-only wiring) |
|