Creating Headless CMS content models via code using the ModelFactory pattern. Use this skill when the developer wants to create, modify, or understand content model definitions, define fields and validators, set up reference fields between models, configure field layouts (including nested layouts inside object or dynamicZone fields), pick the correct Admin UI renderer for a field type (textInput/textInputs, lexicalEditor/lexicalEditors, file/files, objectAccordionSingle/objectAccordionMultiple, etc.), or work with the ModelFactory builder API. Also covers field types (text, longText, number, boolean, datetime, asset, file, ref, object, richText, dynamicZone), list (array) fields via .list() and the singular-vs-plural renderer rule, validation (required, unique, email, pattern, minLength, maxLength, gte, predefinedValues), single-entry (singleton) models via .singleEntry(), model/field tags via .tags(), and field rules via .rules() for access-control and conditional visibility/editability. Includes the correct
Creating Headless CMS content models via code using the ModelFactory pattern. Use this skill when the developer wants to create, modify, or understand content model definitions, define fields and validators, set up reference fields between models, configure field layouts (including nested layouts inside object or dynamicZone fields), pick the correct Admin UI renderer for a field type (textInput/textInputs, lexicalEditor/lexicalEditors, file/files, objectAccordionSingle/objectAccordionMultiple, etc.), or work with the ModelFactory builder API. Also covers field types (text, longText, number, boolean, datetime, asset, file, ref, object, richText, dynamicZone), list (array) fields via .list() and the singular-vs-plural renderer rule, validation (required, unique, email, pattern, minLength, maxLength, gte, predefinedValues), single-entry (singleton) models via .singleEntry(), model/field tags via .tags(), and field rules via .rules() for access-control and conditional visibility/editability. Includes the correct `fields` projection syntax when querying entries via the SDK: `ref` fields use double-`values.` nesting (e.g. `values.author.values.name`) because they resolve to another entry, while `object` and `dynamicZone` sub-fields are inline and use a single `values.` (e.g. `values.author.name`) — getting this wrong silently returns null.
Creating Content Models via Code
TL;DR
Content models are created using the ModelFactory pattern. You define a class implementing ModelFactory.Interface, use the fluent ModelFactory.Builder API to declare fields, validators, layout, and API names, then export with ModelFactory.createImplementation(). Register in webiny.config.tsx as <Api.Extension>.
The ModelFactory Pattern
Every code-based content model follows the same structure:
YOU MUST include the full file path with the .ts extension in the src prop. For example, use src={"/extensions/MyModel.ts"}, NOT src={"/extensions/MyModel"}. Omitting the file extension will cause a build failure.
YOU MUST use export default for the createImplementation() call when the file is targeted directly by an Extension src prop. Using a named export (export const MyModel = ...) will cause a build failure. Named exports are only valid inside files registered via createFeature.
Model Configuration Methods
Method
Purpose
.public({ modelId, name, group })
Creates a public model (accessible via Read API). modelId is the internal DB identifier. group organizes it in the Admin sidebar.
.description("...")
Model description shown in Admin UI
.fields(fields => ({ ... }))
Define all fields using the fluent field builder
.layout([["field1", "field2"], ["field3"]])
Arrange fields in rows in the Admin editor. Each inner array is one row.
.titleFieldId("name")
Which field to use as the entry's display title
.descriptionFieldId("message")
Which field to use as the entry's description
.singularApiName("Product")
Singular name for GraphQL queries (e.g., getProduct)
.pluralApiName("Products")
Plural name for GraphQL queries (e.g., listProducts)
.singleEntry()
Makes the model a singleton (only one entry can exist). Automatically adds the "singleEntry" tag.
.tags(["tag1", "tag2"])
Assign custom tags to the model. The tag "type:model" is always added automatically. Duplicates are removed.
.settings({ ... })
Model settings. Supported properties: aiEntryWizard (boolean), previewPrefix (string — base URL for live preview, e.g. "https://example.com/articles"), previewSlug (string — slug template, e.g. "{values.slug}").
Layout
.layout() takes a two-dimensional array of field IDs. Each inner array is one row in
the Admin editor, and each entry within a row is a column cell. Field IDs must exactly
match the keys used in .fields().
Top-level layout
.layout([
["name", "slug"], // row 1: two columns
["description"], // row 2: one column, full width
["category", "price"]
])
Nested layout inside an object field
object fields have their own .fields() and .layout() that only reference the
object's own sub-fields. The outer model layout should refer to the object field as a
whole; its internal arrangement is owned by the object itself.
dynamicZone is an array field where each entry is one of several named templates.
Every template declares its own fields and its own layout, scoped to that template.
The outer model layout simply references the dynamicZone field by its ID.
Each template config accepts an optional componentName property that maps the template
to a frontend UI component (e.g. "Custom/Hero"). This is used by the CMS live preview
and Website Builder to resolve which React component renders the template's data.
Rule of thumb: a layout can only reference field IDs in the same scope it's declared
in. Model layout references model fields. Object layout references that object's
sub-fields. Each dynamicZone template's layout references only that template's fields.
Field Types and Renderers
Every field type exposes two renderer variants: a single-value renderer (used by
default) and a multi-value renderer (used when the field is marked as a list via
.list()). You MUST pair the renderer with the cardinality: calling .list()
requires a renderer from the list: true column, and omitting .list() requires one
from the list: false column. Using the wrong variant will render incorrectly in the
Admin UI and the field may fail to save values. Invented names (e.g. "fileInput",
"lexicalTextInput", "objectInput", "boolean") will silently misbehave the same way.
Exception: fields.boolean() has no multi-value variant — do not call .list() on
boolean fields.
The authoritative source for these field types is the webiny/api/cms/model barrel export — if you're unsure, check the catalog skill webiny-api-cms-catalog.
Builder Method
Description
Single (list: false)
Multiple (list: true)
fields.text()
Single-line text
"textInput"
"textInputs"
fields.longText()
Multi-line text
"textarea"
"textareas"
fields.richText()
Rich text (Lexical)
"lexicalEditor"
"lexicalEditors"
fields.number()
Numeric value
"numberInput"
"numberInputs"
fields.boolean()
True/false toggle
"switch"
— (not supported)
fields.datetime()
Date/time picker
"dateTimeInput"
"dateTimeInputs"
fields.asset()
Asset (image/video/document with per-usage crop & focal point)
Wrap in a container panel; false for flat inline layout
dynamicZone
addItemLabel
string
"Add Item"
Label for the add-item button
objectAccordionSingle
open
boolean
true
Whether the accordion is expanded by default
objectAccordionSingle
container
boolean
true
Wrap in a container panel; false for flat inline layout
objectAccordionSingle
itemTitle
string
field label
Field ID whose value is used as the accordion title
objectAccordionSingle
itemDescription
string
—
Field ID whose value is used as the accordion description
objectAccordionMultiple
open
boolean
true
Whether each accordion item is expanded by default
objectAccordionMultiple
container
boolean
true
Wrap in a container panel; false for flat inline layout
objectAccordionMultiple
itemTitle
string
field label
Field ID whose value is used as each item's title
objectAccordionMultiple
itemDescription
string
—
Field ID whose value is used as each item's description
objectAccordionMultiple
addItemLabel
string
"Add {label}"
Label for the add-item button
assetField / assetFields
imagesOnly
boolean
false
Only allow image files
assetField / assetFields
accept
string[]
all types
MIME types to allow (e.g. ["image/png", "application/pdf"])
refDialogMultiple
newItemPosition
"first" | "last"
"last"
Where newly picked references are inserted
Ref renderer families
The three ref renderer families look and behave very differently in the Admin UI —
pick the one that fits your UX:
Dialog (refDialogSingle / refDialogMultiple) — opens a modal with a searchable,
filterable picker. Best for large reference sets.
Autocomplete (refAutocompleteSingle / refAutocompleteMultiple) — inline
typeahead input. Best for moderate reference sets.
Inline (refRadioButtons / refCheckboxes) — renders all referenced entries as
inline controls. Best for small, fixed reference sets.
Alternative text/number renderers (with predefinedValues)
When a text or number field uses .predefinedValues([...]), additional renderers
become available:
"radioButtons" — single-value; requires list: false and predefinedValues.
"select" — single-value; requires list: false and predefinedValues.
"dropdown" — deprecated, alias for "select". Use "select" instead.
"checkboxes" — multi-value; requires list: true and predefinedValues.
"tags" — multi-value free-form entry; text only, requires list: true and NO
predefinedValues.
List fields and renderer pluralization
When a field uses .list() (i.e. stores an array of values), the renderer must be the
plural variant from the right-hand column above. Pairing .list() with the singular
renderer causes the Admin UI to render the wrong component and the field will fail to
save correctly.
Correct — list of tags uses the plural "textInputs" renderer:
tags: fields
.text()
.list()
.renderer("textInputs") // plural, because .list() is chained
.label("Tags");
Wrong — singular renderer on a list field (this is the pattern that breaks silently):
tags: fields
.text()
.list()
.renderer("textInput") // WRONG: should be "textInputs"
.label("Tags");
The same rule applies to every field type that has both variants:
richText().list() → "lexicalEditors", file().list() → "files",
longText().list() → "textareas", number().list() → "numberInputs",
object().list() → "objectAccordionMultiple", and so on.
For ref() fields the pluralization rule is the same but the singular/multiple renderers
have distinct names (e.g. refDialogSingle → refDialogMultiple) — see the table.
Layout Fields
Layout fields are UI-only elements that do not store data. They decorate the editor
form with visual structure. Place them in .fields() like data fields, and reference
them by key in .layout().
All layout fields inherit the base methods: .label(), .help(), .description(),
.note(), .fieldId(), .rules().
A colored callout banner. Use .alertType() to set the severity.
.fields(fields => ({
warning: fields
.uiAlert()
.label("Changes to this section require approval.")
.alertType("warning"),
title: fields.text().renderer("textInput").label("Title")
}))
.layout([["warning"], ["title"]])
uiTabs
Groups fields into tabs. Each tab has its own fields and layout. Fields inside tabs
are hoisted to the model level (flat field list), but visually grouped under their tab.
The outer .layout() references "tabs" as a single cell. Tab-internal layouts only
reference their own field keys (same scoping rule as object/dynamicZone layouts).
Field Validators (Chainable)
Validator
Description
Example
.required("msg")
Field is required
.required("Name is required")
.unique()
Value must be unique across entries
.unique()
.email()
Must be a valid email
.email()
.pattern(regex, msg)
Must match a regex
.pattern("^[a-z0-9-]+$", "Lowercase and hyphens only")
Make the field accept multiple values (arrays). Requires a multi-value renderer variant — see Field Types table.
.models([{ modelId: "..." }])
For ref() fields: which models can be referenced
.tags(["tag1"])
Assign tags to a field (e.g., "$bulk-edit")
.rules([...])
Conditional visibility/editability rules — see Field Rules section
Field Rules
.rules() accepts an array of FieldRule objects that control field visibility and
editability in the Admin UI. Rules are evaluated client-side. Available on all field
types (data fields and layout fields — separators, alerts, tabs).
Show/hide or enable/disable a field based on the current user's identity or team membership
"identity"
"admin:<userId>" or "team:<teamSlug>"
"condition"
Show/hide or enable/disable a field based on the current entry's field values (reactive, updates live)
Field path, e.g. "status" or "seo.title"
The value to compare against (type depends on operator)
Actions
action
Effect
"hide"
Hides the field entirely from the editor
"disable"
Shows the field but makes it read-only (greyed out, not editable)
Condition operators
Operators available for type: "condition" rules, grouped by target field type:
Operator
Label
Applicable to
value
"=="
Equals
text, number, boolean, datetime
The value to match
"!="
Not equals
text, number, boolean, datetime
The value to not match
">"
Greater than
number, datetime
Numeric/date threshold
"<"
Less than
number, datetime
Numeric/date threshold
">="
Greater or equal
number, datetime
Numeric/date threshold
"<="
Less or equal
number, datetime
Numeric/date threshold
"contains"
Contains
text, long-text
Substring to search for
"startsWith"
Starts with
text, long-text
Prefix to match
"endsWith"
Ends with
text, long-text
Suffix to match
"isEmpty"
Is empty
all field types
null (value is ignored)
"isNotEmpty"
Is not empty
all field types
null (value is ignored)
Access control operators
Operator
Description
"matches"
Checks if the current user's identity or team matches value
Target field paths (condition rules)
The target for condition rules is a dot-separated field path relative to the form
root. Use the field's fieldId (the key in the .fields() callback).
Simple field: "status"
Nested inside object: "seo.title", "address.city"
Inside a list item (current index): "items.$.name" — the $ resolves to the
current list index at evaluation time
Array length: "items.length" — evaluates to the number of items in the array
Static relative path: "$.fieldId" — resolves relative to the parent object or
template that contains the field. Use this inside object, dynamicZone template,
or uiTabs scopes to reference a sibling field without hard-coding the full path.
For example, inside a dynamicZone template, "$.enabled" resolves to the sibling
enabled field within the same template instance. This is the recommended approach
for rules inside nested scopes — it keeps the rule portable and avoids coupling to
the parent field's name.
Examples
Access control — hide field from non-marketing team:
Static $. paths — referencing siblings inside a nested scope:
Use $. to target a sibling field within the same parent object or dynamicZone
template. The $ resolves to the parent path at runtime, so the rule stays portable
regardless of the outer structure.
In this example, "$.global" resolves to the global field within the same
dynamicZone template instance. Without the $. prefix, you would need to hard-code the
full path (e.g. "bannerTypes.0.global"), which breaks across list indices. The same
pattern works inside object fields and uiTabs scopes.
Querying ref, object, and dynamicZone fields
When you read entries via the Webiny SDK (or GraphQL), the fields array tells the API
which fields to return. The nesting syntax depends on the field type, and getting it
wrong silently returns null for the nested value.
ref fields — double values. nesting
A reference field returns the referenced entry, which itself has its own values
wrapper around its fields. To project a sub-field of a referenced entry, you must
include the inner values. segment.
If author is a .list() ref field, the same rule applies — each item in the returned
array is an entry with its own values wrapper, so you still write
values.authors.values.name.
object and dynamicZone fields — no inner values.
Object and dynamicZone sub-fields are stored inline on the parent entry, with no
intermediate values wrapper. Access sub-fields with a plain dotted path.
fields.ref() → the field resolves to another entry, so its sub-path goes through
.values. (e.g. values.author.values.name).
fields.object() / fields.dynamicZone() → the field is inline, so its sub-path
is plain (e.g. values.author.name).
Mixing the two up is the most common cause of "the query worked but the field is
null" bugs. If you're unsure, cross-check the field definition in the model file.