Form
Display fields that submit data to a Backlit action.
A form block collects data and submits it to one resource action. The action owns the validation schema. The schema also gives the form its input type, so TypeScript checks field names and initial-value keys.
Use any Standard Schema validator for the action. These examples use Zod.
API
form(action: ActionDefinition): FormBuilder
| Method | Input | Result |
|---|---|---|
fields |
...FieldBuilderContract[] |
Creates one block of fields that submit together. |
setContent |
Producer[] |
Sets the blocks inside the form. |
withInitialData |
FormInitialData<ActionInput> |
Sets the values that the form shows first. |
setSubmitAction |
{ label: string } |
Configures the submit action. |
setClearAction |
{ label: string } |
Adds and configures the clear action. |
onDefect |
(defect: Defect) => void |
Handles an initial value that cannot be converted. |
form.fields returns a FormFieldsBuilder. Call peers(...sets) on this
builder to place related fields on the same line. A field not in a peer set
stands alone.
Calling setContent, withInitialData, setSubmitAction, or setClearAction
again replaces the previous value.
Basic form
Create the form from its target action. Then add a field block to its content. The protocol target uses the resource and action names.
import { form, defineResource, fields } from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
])
const createOrder = orders.action(
'create',
z.object({ customerName: z.string() }),
() => {}
)
orders.view('create', () => {
const formBuilder = form(createOrder)
return formBuilder.setContent([
formBuilder.fields(...orders.pick('customerName')),
])
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "create",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "create"
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
}
]
}
]
}
}
]
}
}
}Content composition
A form can contain the same blocks as a view. Place field blocks in panels or sections when the inputs need visible groups.
import {
form,
defineResource,
fields,
panel,
section,
} from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
fields.text('notes', { label: 'Notes' }),
])
const createOrder = orders.action(
'create',
z.object({
customerName: z.string(),
notes: z.string(),
}),
() => {}
)
orders.view('create', () => {
const formBuilder = form(createOrder)
return formBuilder.setContent([
panel([
formBuilder.fields(...orders.pick('customerName')),
]).setTitle('Customer'),
section('Additional information').setContent([
formBuilder.fields(...orders.pick('notes')),
]),
])
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "create",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "create"
},
"slots": {
"content": [
{
"kind": "panel",
"title": "Customer",
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
}
]
}
]
}
},
{
"kind": "section",
"title": "Additional information",
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "notes",
"label": "Notes"
}
]
}
]
}
}
]
}
}
]
}
}
}Initial values
Use withInitialData for edit data or create-form defaults. Backlit converts each
value to the wire type of its displayed field. A key without a displayed field
passes through without conversion.
import { form, defineResource, fields } from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.number('quantity', { label: 'Quantity' }),
fields.date('placedAt', { label: 'Order date' }),
])
const updateOrder = orders.action(
'update',
z.object({
quantity: z.number(),
placedAt: z.string(),
}),
() => {}
)
orders.view('edit', () => {
const formBuilder = form(updateOrder)
return (
formBuilder
.setContent([
formBuilder.fields(
...orders.pick('quantity', 'placedAt')
),
])
.withInitialData({
quantity: '2',
placedAt: new Date('2026-08-13T00:00:00.000Z'),
})
)
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "edit",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "update"
},
"initial": {
"quantity": 2,
"placedAt": "2026-08-13T00:00:00.000Z"
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "number",
"name": "quantity",
"label": "Quantity"
},
{
"kind": "date",
"name": "placedAt",
"label": "Order date"
}
]
}
]
}
}
]
}
}
}Submit label
Use setSubmitAction to replace the renderer’s default submit label.
import { form, defineResource, fields } from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
])
const createOrder = orders.action(
'create',
z.object({ customerName: z.string() }),
() => {}
)
orders.view('create', () => {
const formBuilder = form(createOrder)
return (
formBuilder
.setContent([
formBuilder.fields(...orders.pick('customerName')),
])
.setSubmitAction({ label: 'Place order' })
)
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "create",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "create"
},
"submit": {
"label": "Place order"
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
}
]
}
]
}
}
]
}
}
}Clear label
Use setClearAction to add a control that restores the initial values in the client.
Clearing a form does not send a request.
import { form, defineResource, fields } from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
])
const createOrder = orders.action(
'create',
z.object({ customerName: z.string() }),
() => {}
)
orders.view('create', () => {
const formBuilder = form(createOrder)
return (
formBuilder
.setContent([
formBuilder.fields(...orders.pick('customerName')),
])
.setClearAction({ label: 'Reset form' })
)
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "create",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "create"
},
"clear": {
"label": "Reset form"
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
}
]
}
]
}
}
]
}
}
}Global remapper
Use remappers.registerRemappers when source data needs a different conversion.
Register it for the input role. The change applies to all forms. A form does
not accept local remapper overrides.
import {
form,
defineResource,
fields,
remappers,
} from '@backlit/sdk'
import { z } from 'zod'
remappers.registerRemappers('input', {
number(value, _field, _record, _parent, report) {
const parsed = Number(String(value).replaceAll(',', ''))
return Number.isNaN(parsed)
? report('expected a numeric value')
: parsed
},
})
const orders = defineResource('orders', [
fields.number('quantity', { label: 'Quantity' }),
])
const updateOrder = orders.action(
'update',
z.object({ quantity: z.number() }),
() => {}
)
orders.view('edit', () => {
const formBuilder = form(updateOrder)
return formBuilder
.setContent([
formBuilder.fields(...orders.pick('quantity')),
])
.withInitialData({ quantity: '1,204' })
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "edit",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "update"
},
"initial": {
"quantity": 1204
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "number",
"name": "quantity",
"label": "Quantity"
}
]
}
]
}
}
]
}
}
}Defect reporter
Use onDefect to report an initial value that a remapper cannot convert. The
invalid key is not included in the protocol output. The default reporter writes
a warning to the console.
import { form, defineResource, fields } from '@backlit/sdk'
import type { Defect } from '@backlit/sdk/types'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.number('quantity', { label: 'Quantity' }),
])
const updateOrder = orders.action(
'update',
z.object({ quantity: z.number() }),
() => {}
)
const defects: Defect[] = []
orders.view('edit', () => {
const formBuilder = form(updateOrder)
return (
formBuilder
.setContent([
formBuilder.fields(...orders.pick('quantity')),
])
.withInitialData({ quantity: 'many' })
.onDefect((defect) => {
defects.push(defect)
})
)
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "edit",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "update"
},
"initial": {},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "number",
"name": "quantity",
"label": "Quantity"
}
]
}
]
}
}
]
}
}
}Form inside a zone
A zone can produce a form when the form must refresh independently from the other view content.
import {
form,
defineResource,
fields,
zone,
} from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
])
const updateOrder = orders.action(
'update',
z.object({ customerName: z.string() }),
() => {}
)
orders.view('edit', () =>
zone('order-form', () => {
const formBuilder = form(updateOrder)
return formBuilder.setContent([
formBuilder.fields(...orders.pick('customerName')),
])
})
)
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "edit",
"slots": {
"content": [
{
"kind": "zone",
"name": "order-form",
"resource": "orders",
"view": "edit",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "update"
},
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
}
]
}
]
}
}
]
}
}
]
}
}
}All options
This example uses peer fields, composed content, initial values, both control labels, and a defect reporter in one form.
import {
form,
defineResource,
fields,
panel,
} from '@backlit/sdk'
import { z } from 'zod'
const orders = defineResource('orders', [
fields.text('customerName', { label: 'Customer' }),
fields.number('quantity', { label: 'Quantity' }),
])
const updateOrder = orders.action(
'update',
z.object({
customerName: z.string(),
quantity: z.number(),
}),
() => {}
)
orders.view('edit', () => {
const formBuilder = form(updateOrder)
return formBuilder
.setContent([
panel([
formBuilder
.fields(
...orders.pick('customerName', 'quantity')
)
.peers(['customerName', 'quantity']),
]).setTitle('Order'),
])
.withInitialData({
customerName: ' Acme ',
quantity: '2',
})
.setSubmitAction({ label: 'Save order' })
.setClearAction({ label: 'Reset form' })
.onDefect(() => {})
})
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "edit",
"slots": {
"content": [
{
"kind": "form",
"target": {
"resource": "orders",
"action": "update"
},
"initial": {
"customerName": " Acme ",
"quantity": 2
},
"submit": {
"label": "Save order"
},
"clear": {
"label": "Reset form"
},
"slots": {
"content": [
{
"kind": "panel",
"title": "Order",
"slots": {
"content": [
{
"kind": "form-fields",
"fields": [
{
"kind": "text",
"name": "customerName",
"label": "Customer"
},
{
"kind": "number",
"name": "quantity",
"label": "Quantity"
}
],
"peers": [
[
"customerName",
"quantity"
]
]
}
]
}
}
]
}
}
]
}
}
}See Form fields for field ordering and peer placement.
Conditions and repeated rows
Use when, ref, all, any, and none to create typed form conditions.
Use repeater for arrays of objects. See Conditions
and Repeaters.
A regular form cannot contain another form. Put a small action form in an Action target. Form targets use the current view’s arguments, so a form view and its action must use compatible parameter order. The initial-data keys are checked against the schema. Values can be raw input for the remappers; they are not restricted to already converted schema values.