Skip to content
Backlit
Esc
navigateopen⌘Jpreview
On this page

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.

Was this page helpful?