Fields
Companions:
- Rich Text — the Lexical adapter and how
lexicalEditor()/ per-field editor overrides plug in. - Collections — collection-level admin (columns, layout, preview URL, custom list views).
- Architecture — the schema / admin split (Django-style model vs ModelAdmin) at the framework level.
Overview
A field is one piece of a collection's content — a title, a body, a publish date. You declare fields on two sides of a deliberate split, and knowing which side a thing belongs on is most of what this document teaches.
- The schema side describes what the field is: its name, type, validation, defaults, and schema-level adapter config. It is pure data and must stay tsx-loadable.
- The admin side describes how the field renders in the dashboard: slot-component overrides for the label, input, help text, and adornments, plus the per-field richtext editor swap. React lives here.
Read this document when you are adding fields to a collection, reaching for a reusable field helper, or replacing part of a field's rendering. It is the working reference for both sides — how the split works, how to write helpers for each side, and how the 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, and the admin's fields{} map attaches presentation, keyed by schema path — a top-level name, or a dotted path to a declaration nested in group or array structure.
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(),}11. Show a field conditionally (condition)
Schema-side. When condition is present, the admin form renders the field only while the function returns true — re-evaluated on every form edit. siblingData is the field's immediate scope (the enclosing group / array item), so conditions inside array items observe their own item.
Edit: apps/webapp/byline/collections/<name>/schema.ts
fields: [ { name: 'hasExpiry', label: 'Has Expiry', type: 'checkbox' }, { name: 'expiresOn', label: 'Expires On', type: 'datetime', optional: true, condition: (_data, siblingData) => Boolean(siblingData.hasExpiry), },]12. Write across fields from a field hook (setFieldValue)
Schema-side. Field hooks receive ctx.setFieldValue(path, value) for cross-field behaviour — mutual exclusivity, clearing a dependent field when its driver changes. It's a raw store write: the target field's own hooks do not run, but the write emits a normal field.set patch, so it persists on save.
Edit: apps/webapp/byline/collections/<name>/schema.ts
{ name: 'featured', type: 'checkbox', hooks: { beforeChange: ({ value, path, data, setFieldValue }) => { if (value !== true) return // Checking this item unchecks every other item's `featured`. const ownIndex = path.match(/^items\[(\d+)\]/)?.[1] ;(data.items ?? []).forEach((item: any, index: number) => { if (String(index) !== ownIndex && item?.featured === true) { setFieldValue(`items[${index}].featured`, false) } }) }, },}→ Field hooks and cross-field writes
Architecture
The schema / admin split
A field lives in two places at once:
- Schema (
collections/<name>/schema.ts) — aCollectionDefinitionreturned bydefineCollection. Pure data plus plain functions over data: field names, types, validation, defaults, schema-level adapter config (editorConfig,embedRelationsOnSave,localized, …), and the editor-behaviour hints that are functions of form data (validate,condition, client-sidehooks). 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 the_bylineroute registers frombeforeLoadfor child loaders and fromroute.lazy.tsxfor component render and hydration.
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 Rich Text for the full editor configuration story.
Schema paths vs instance paths
Byline addresses fields with two notations, and every config option takes exactly one of them. Which one depends on whether the thing being addressed is a declaration or a value.
- Schema paths are dotted and index-free:
files.filesGroup.publicationFile,faq.answer. They address a field declaration, so one entry names the field wherever it occurs — in every array item. Ablocksfield is followed by a block type (content.photoBlock.alt), which is what tells two blocks declaring the same field name apart. You write these in the adminfields{}maps and in the upload hook registry (ServerConfig.hooks.uploads, prefixed with the collection path); boot-validation errors quote them back to you. - Instance paths carry bracket selectors:
files[id=file-id].filesGroup.publicationFile,content[id=block-id].faq[id=item-id].answer. They address a value in one item of one document. No block type here — the addressed item carries its own_type. The form and patch systems prefer stable_idselectors and retain positional[2]selectors for id-less compatibility data. These are what the patch system, the form store, field-hookctx.path, andsetFieldValuedeal in; you rarely write one by hand.
Admin fields{} keys are the one deliberate exception: they never traverse a blocks field at all, even with a block type. Fields inside a block take their overrides from the blockType-keyed blockAdmin registry instead, so that one registration applies wherever the block renders. Boot validation rejects a collection-level key that reaches into a block, and points at blockAdmin.
Boot validation also rejects fields{} keys that carry a selector or don't resolve to a declaration, so a mixed-up notation fails at startup rather than silently at render time.
See Path Grammar for the full model — the shared segment AST behind both notations, the storage and patch formats, and why each is shaped the way it is.
Per-field admin config for nested fields
A collection's fields{} map reaches nested declarations directly with a dotted schema-path key:
// admin.tsx — override the editor of a richText field inside an array item's groupfields: { 'files.filesGroup.notes': { editor: PlainLexicalEditor },}The override applies to that field in every item of the array — schema paths address declarations, not items. The one boundary is blocks: a collection-level key never reaches into a block; that's defineBlockAdmin's job below.
Per-block field admin config (defineBlockAdmin)
Fields inside a block are addressed by the block-scoped counterpart, defineBlockAdmin: an admin config keyed by schema paths relative to the block root, registered site-wide on ClientConfig.blockAdmin and applied wherever the block renders — any collection, any nesting. Nested declarations inside the block use the same dotted keys (faq.answer — see apps/webapp/byline/blocks/faq-block.admin.ts, the reference).
// blocks/quote-block.admin.ts (admin-side — may carry React)import { defineBlockAdmin } from '@byline/core'import { QuoteBlock } from './quote-block.js'
export const QuoteBlockAdmin = defineBlockAdmin(QuoteBlock, { fields: { quoteText: { editor: PlainLexicalEditor }, // same FieldAdminConfig shape },})
// admin.config.tsblockAdmin: [QuoteBlockAdmin],The same schema/admin split applies: defineBlock() files stay React-free; the .admin.ts companion imports the schema, never the reverse. Registration is keyed by blockType because blocks are cross-collection units — the same block object shared by several collections gets one admin identity (mirroring how type generation dedupes block contracts). Boot validation rejects unknown blockTypes, duplicates, and fields keys that don't resolve as schema paths within the block. See Blocks for the full design.
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.
Conditional visibility (condition)
Every field accepts an optional visibility predicate:
condition?: (data: Record<string, any>, siblingData: Record<string, any>) => booleanWhen present, the admin form renders the field only while the function returns true. The predicate is re-evaluated on every form edit (the same meta-subscribe loop that drives tab-level condition functions), and the field only re-renders when the boolean actually flips — a stable condition costs one function call per edit.
The two arguments. data is the full live form data. siblingData is the field's immediate scope — the enclosing group or array item's values. For a field at files[id=file-id].filesGroup.thumbnailPage, siblingData is that item's filesGroup object, so a condition inside an array item observes its own item, not the document root (and not item 0). For root-level fields, siblingData is the same object as data. This scoping is what lets one field definition drive per-item behaviour across an entire array.
A rendering hint, not enforcement. condition sits in the same family as readOnly: schema-side properties that shape the editing experience without any server-side effect. Consequences worth knowing:
- The hidden field's stored value is retained. Hiding emits no clearing write; re-showing the field restores its last value (the widget re-seeds from the live form store, so an edit made before hiding is not visually reverted).
- Condition-hidden fields are exempt from client-side validation — a field the editor cannot see must not block submit. Submit-time
beforeValidatehooks are likewise skipped while hidden. - Server-side schema validation knows nothing about conditions. A required conditional field must still arrive with a value, so pair conditionally-hidden required fields with
optional: trueor adefaultValue.
Conditions are plain functions over form data, so they are safe in the isomorphic schema — the same rule as inline field hooks (no server-only imports).
Tab-level conditions are the admin-side sibling of this feature: TabDefinition.condition(data) on a CollectionAdminConfig shows/hides an entire tab. Field-level condition lives on the schema because it is data-dependent per item — a condition inside an array item observes its own item's values, which a declaration-addressed admin override (one entry per schema path) has no way to express.
Field hooks and cross-field writes
Fields accept two client-side hooks that run in the admin form while editing (distinct from collection lifecycle hooks, which run server-side — see Collections):
hooks: { beforeValidate?: FieldBeforeValidateFn | FieldBeforeValidateFn[] beforeChange?: FieldBeforeChangeFn | FieldBeforeChangeFn[]}- beforeValidate is advisory: the value is always committed; returning
{ error }displays a per-field error without blocking typing. Also runs once at submit time, before form validation. - beforeChange runs before the value is committed and a patch is emitted. Returning
{ value }substitutes the committed value (trim, slug); returning{ error }blocks the change entirely.
Both receive a FieldHookContext:
{ value: any // the incoming value previousValue: any data: Record<string, any> // full live form data path: string // e.g. 'files[id=file-id].filesGroup.generateThumbnail' field: Field // the field definition operation: 'change' | 'submit' setFieldValue: (path: string, value: any) => void // cross-field write}setFieldValue semantics. It writes another field's value in the form store. It is a raw write — the target field's own hooks do not run, which forecloses hook recursion — but it is otherwise a normal form edit: it emits a field.set patch (so the write persists on save), marks the form dirty, and store-subscribed widgets re-render immediately. Paths use the same dot + bracket notation as ctx.path.
Worked example — mutual exclusivity across array items. A collection carries a files array where each item can request a generated thumbnail, but only one file may be the thumbnail source. Checking one item's box must uncheck the others. ctx.path carries the item's index, so the hook knows which item it is:
// schema.ts — inside the files array's item group{ name: 'generateThumbnail', label: 'Generate Thumbnail', type: 'checkbox', hooks: { // Only one file may be the thumbnail source: checking this box // unchecks every other item's checkbox. The unchecks are normal // field.set patches, so they persist on save. beforeChange: ({ value, path, data, setFieldValue }) => { if (value !== true) return const ownIndex = path.match(/^files\[(\d+)\]/)?.[1] const items: any[] = Array.isArray(data.files) ? data.files : [] items.forEach((item, index) => { if (String(index) !== ownIndex && item?.filesGroup?.generateThumbnail === true) { setFieldValue(`files[${index}].filesGroup.generateThumbnail`, false) } }) }, },},{ name: 'thumbnailPage', type: 'integer', defaultValue: 1, // Rendered only while this item's checkbox is checked — `siblingData` // is this array item's scope, so each item follows its own checkbox. condition: (_data, siblingData) => Boolean(siblingData.generateThumbnail), helpText: 'Choose a page number from the PDF file to use as the thumbnail.',},The radio-group behaviour composes from the two features: checking a box unchecks its siblings (persisted patches) → each write re-evaluates conditions → the old item's thumbnailPage hides and the new item's appears.
Server-side backstop. Client hooks are UI-only: API writes, seeds, and pre-existing documents can still arrive with several boxes checked. If the invariant matters beyond the editor, normalise it in the collection's server-side beforeCreate / beforeUpdate hooks. Prefer the item that was not checked in the previous version (the newly checked one); match items across versions by their stable _id (read-only use — never write _id yourself):
// hooks.ts (collection lifecycle hooks — server-side)const enforceSingleThumbnailSource = ( data: Record<string, any>, originalData?: Record<string, any>): void => { const items: any[] = Array.isArray(data.files) ? data.files : [] const checked = items.filter((item) => item?.filesGroup?.generateThumbnail === true) if (checked.length <= 1) return
const previousItems: any[] = Array.isArray(originalData?.files) ? originalData.files : [] const previouslyCheckedIds = new Set( previousItems .filter((item) => item?.filesGroup?.generateThumbnail === true) .map((item) => item?._id) .filter(Boolean) )
const winner = checked.find((item) => item?._id && !previouslyCheckedIds.has(item._id)) ?? checked[0]
for (const item of checked) { if (item !== winner) item.filesGroup.generateThumbnail = false }}
export default defineHooks({ beforeCreate: ({ data }) => enforceSingleThumbnailSource(data), beforeUpdate: ({ data, originalData }) => enforceSingleThumbnailSource(data, originalData),})(before* hooks may mutate data in place by contract. If the collection-hook module imports server-only code, omit it from the schema and register its loader through ServerConfig.hooks.collections; see Collections.)
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' }.
Hiding a required field without a default
A condition only hides the widget — server-side validation still expects the value. A required field that is hidden on first save (its condition starts false) arrives empty and fails the server's schema validation, even though the client skipped it. Give conditional required fields optional: true or a defaultValue.
Expecting condition to prune data
Hiding a field does not clear it. The stored value rides along in every save while hidden (that's deliberate — re-showing restores it, and toggling a checkbox doesn't destroy a carefully chosen sibling value). If downstream code must not see the value while the condition is off, gate on the driving field server-side (e.g. ignore thumbnailPage when generateThumbnail is false) — don't assume absence.
Code map
Concern | Location |
|
|
|
|
|
|
Condition evaluation (meta-subscribe loop) |
|
Field-hook pipeline (change-time) |
|
Field-hook pipeline (submit-time) + validation exemption |
|
|
|
|
|
|
|
Block admin boot validation |
|
Block admin resolution + threading |
|
|
|
Code field widget (lazy CodeMirror 6) |
|
|
|
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) |
|