- name
- webiny-form-model
- description
- Building forms with the FormModel system — field types, renderers, layout, validation, conditional rules, computed fields, and dynamic zones. Use this skill when the developer needs to define form fields with the builder API, choose renderers, build layouts with tabs/rows/separators, add validation (Zod or imperative), use conditional visibility/disable rules, create computed fields, or work with object fields and templates (dynamic zones).
# Form Model
## TL;DR
The Form Model is Webiny's declarative form system. Define fields with a fluent builder API (`fields.text()`, `fields.datetime()`, etc.), arrange them with a layout builder (`layout.row()`, `layout.tabs()`, etc.), and validate with Zod schemas or imperative rules. Fields support conditional visibility, computed values, reactive context from other fields (`.context()`), and deeply nested object/list structures with templates (dynamic zones).
## Field Types
All fields are created via the `fields` registry callback. Each builder method returns a chainable builder.
### Text
```typescript
fields.text();
```
Default renderer: `textInput`. Value: `string | null`.
```typescript
fields.text().label("Title").placeholder("Enter title").required("Title is required")
fields.text().renderer("textarea", { rows: 4 })
fields.text().list().renderer("tags").defaultValue([])
fields.text().list().renderer("textInputs", { addItemLabel: "Add text" })
fields.text().list().renderer("textareas", { addItemLabel: "Add description" })
fields.text().renderer("codeEditor", { language: "html", height: 300 })
fields.text().options([
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" }
]) // auto-switches to "select" renderer
fields.text().options([...]).renderer("radioButtons")
fields.text().list().options([...]).renderer("checkboxes")
// Dynamic options — callback receives { field, form }, re-evaluated reactively
fields.text().options(({ form }) => {
const type = form.field("general.type").getValue();
return getOptionsForType(type);
})
```
### Number
```typescript
fields.number();
```
Default renderer: `numberInput`. Value: `number | null`. Auto-normalizes to number.
```typescript
fields.number().label("Count").placeholder("0").required();
fields.number().list().renderer("numberInputs", { addItemLabel: "Add number" });
fields.number().options([
{ label: "Tier 1", value: 100 },
{ label: "Tier 2", value: 200 }
]);
```
### Boolean
```typescript
fields.boolean();
```
Default renderer: `switch`. Value: `boolean | null`.
```typescript
fields.boolean().label("Featured").defaultValue(false);
```
### DateTime
```typescript
fields.datetime();
```
Default renderer: `dateTimeInput`. Pick a variant method to set the subtype:
| Variant | Value Format | Example |
| ----------------------------------------- | ------------------------------ | ----------------------- |
| `.dateOnly()` | `"2026-05-01"` | Birthdays, due dates |
| `.timeOnly()` | `"14:30:00"` | Opening hours |
| `.withTimezone()` | `"2026-05-01T14:30:00+02:00"` | Events tied to a locale |
| `.withoutTimezone()` | `"2026-05-01T14:30:00.000Z"` | Timestamps, logs |
| `.monthOnly()` | `"2026-05"` | Billing cycles |
| `.weekOnly({ startsOn: 1 })` | `"2026-W18"` | Sprint planning |
| `.yearOnly({ range: [2020, 2035] })` | `2026` (number) | Fiscal years |
| `.dateRange()` | `{ from: "...", to: "..." }` | Vacation requests |
| `.multipleDates()` | `["2026-05-01", "2026-05-03"]` | Blackout dates |
| `.multipleMonths()` | `["2026-01", "2026-03"]` | Seasonal availability |
| `.multipleYears({ range: [2020, 2035] })` | `[2024, 2025, 2026]` | Multi-year budgets |
Additional chainable methods:
```typescript
.presets([
{ label: "Today", value: () => new Date() },
{ label: "In a week", value: () => addDays(new Date(), 7) }
])
.displayFormat("dd/MM/yyyy") // date-fns format tokens
.list() // switches renderer to "dateTimeInputs"
```
### File
```typescript
fields.file();
```
Default renderer: `filePicker`. Value: `FileValue | null` (object with `id`, `name`, `size`, `mimeType`, `src`, `width`, `height`).
```typescript
fields.file().label("Image");
```
### File URL
```typescript
fields.fileUrl();
```
Default renderer: `fileUrlPicker`. Value: `string | null` (URL only).
```typescript
fields.fileUrl().label("Image URL");
```
### Lexical
```typescript
fields.lexical();
```
Default renderer: `lexical`. Value: `RichTextValueWithHtml | null` (`{ state: string; html: string }`).
```typescript
fields.lexical().label("Content").required("Content is required");
```
### Password
```typescript
fields.password();
```
Default renderer: `passwordInput`. Value: `string | null`.
```typescript
fields.password().label("Password").required("Password is required");
```
### Permissions
```typescript
fields.permissions();
```
Default renderer: `permissions`. Value: `Record<string, unknown>[]`. Has a built-in Zod schema requiring at least one permission entry.
```typescript
fields.permissions().label("Permissions");
```
### Roles Multi-Select
```typescript
fields.rolesMultiSelect();
```
Default renderer: `rolesMultiSelect`. Value: `unknown[]`.
```typescript
fields.rolesMultiSelect().label("Roles");
```
### Object
```typescript
fields.object();
```
Default renderer: `objectAccordionSingle`. For nested structures, lists, and dynamic zones.
```typescript
// Simple nested object
fields.object().label("Address").fields(f => ({
street: f.text().label("Street"),
city: f.text().label("City"),
zip: f.text().label("ZIP")
}))
// List of objects
fields.object().list().label("Authors").fields(f => ({
name: f.text().label("Name").required(),
email: f.text().label("Email")
}))
// Dynamic zone (single template selection)
fields.object().label("Content Block")
.template("hero", t => {
t.label("Hero Banner")
.icon({ type: "icon", name: "fas/image" })
.fields(f => ({
heading: f.text().label("Heading").required(),
image: f.file().label("Image")
}));
})
.template("text", t => {
t.label("Rich Text").fields(f => ({
body: f.text().label("Body").renderer("textarea")
}));
})
// Dynamic zone list (multiple items, each picks a template)
fields.object().list().label("Page Sections")
.renderer("dynamicZone", { container: false })
.template("hero", t => { ... })
.template("cta", t => { ... })
// Key-value list
fields.object().list().label("Meta Tags")
.renderer("keyValueTags", { addItemLabel: "Add tag" })
.fields(f => ({
name: f.text().placeholder("Name"),
content: f.text().placeholder("Content")
}))
```
Template visibility can be conditional:
```typescript
.template("premium", t => {
t.label("Premium Widget")
.visible(form => form.field("plan").getValue() === "enterprise")
.fields(f => ({ ... }));
})
```
## Common Builder Methods
These are available on **all** field types:
| Method | Description |
| ----------------------------- | ------------------------------------------------------- |
| `.label(text)` | Field label |
| `.description(text)` | Description text below the field |
| `.help(text)` | Help text |
| `.note(text)` | Supplementary note |
| `.placeholder(text)` | Input placeholder |
| `.defaultValue(value)` | Default value (can be a function for dynamic defaults) |
| `.required(message?)` | Mark as required |
| `.requiredWhen(fn, message?)` | Conditionally required based on other field values |
| `.schema(zodSchema)` | Zod validation schema |
| `.renderer(name, settings?)` | Override the default renderer |
| `.options([...])` | Add value options (auto-switches text/number to select) |
| `.list()` | Convert to array field |
| `.hidden()` | Hide the field (value still in form data) |
| `.hiddenWhen(fn)` | Conditionally hide based on form state |
| `.disabled(value?)` | Disable the field |
| `.disabledWhen(fn)` | Conditionally disable based on form state |
| `.rules([...])` | Conditional visibility/disable rules |
| `.computed(fn)` | Always-computed value from other fields |
| `.computedUntilDirty(fn)` | Computed until user edits the field |
View on GitHub