10x-media plugins
Form Builder

Fields

The core field types, the registry, and defining your own with defineFormField.

Form fields are authored as Payload blocks: one block type per registered field type. A field type is defined once and yields four facets: its admin authoring fields, a typed isomorphic validate, a localized format, and a value kind that types the value flowing through validation and formatting.

Core field types

text, textarea, email, number, date, select, checkbox, file, consent, calculation, repeater, and message ship as built-ins; country and state are opt-in. All are built through the same defineFormField primitive as custom types, so nothing about them is privileged. file, consent, and calculation have their own pages: Uploads (the file type only exists once an uploads collection is configured), Consent (likewise, only once consent sources are), Calculations.

Authoring layout

Each field block's Field tab lays the shared basics out in rows: name beside label, width beside placeholder, then description, with the required checkbox last. width is required with a full default, so every field carries an explicit value for the layout grid. The rows are presentational only; stored data paths stay flat.

A block's collapsed row header shows the field's own label once it has one, falling back to its translated type name, instead of Payload's default block-name placeholder that reads "Untitled" until filled in.

A type leaves out the basics it never reads via omitShared, so an author is never offered a setting the renderer ignores. checkbox, file, repeater, and date have no placeholder (every browser ignores one on a date input); calculation, being derived and read-only, has neither placeholder nor required. An omission that empties a row drops the row, and a lone survivor spans it. name is never omittable: it is the key the answer is stored under.

Content localization

Content-bearing authoring fields (labels, placeholders, descriptions, select option labels, repeater add labels, message content) are localized: true by default, so on a host with Payload localization configured, editors translate form content per locale out of the box. Payload strips the flag on hosts without localization, so the default is safe everywhere. Opt out with the plugin option localizeContent: false (see i18n for the content-versus-UI-strings distinction and Customization for the interplay with spread-overrides).

Message

The message type is display-only rich text rendered inline between fields: section headings, explanatory copy, legal notices. Its value kind is 'none': it is never validated and never written to submissions. Its content is authored with the plugin's rich text editor and follows content localization; recall tokens in it resolve against the current answers, calc values included.

Message is a bare block: the block is its content field alone. No name, label, width, placeholder, description, required flag, or validation rules, since none of them carry meaning for copy that holds no value. Nor a visibleWhen, so a message cannot be conditionally shown: when copy has to react to an answer, put each variant on its own flow step, or register a display-only custom type (value: 'none' without bare), which keeps the shared chrome and with it the condition.

Being nameless, a message instance is identified by the block row id Payload assigns it. Everywhere a field is addressed by key, the key is its name if it has one, else its row id, so flow step assignment works for bare blocks the same as for named fields (the flow builder lists them under their type label, numbered when repeated). Bare blocks are excluded from repeater subFields, whose row plumbing is name-keyed.

Date

The date type stores an ISO YYYY-MM-DD string and renders as a native <input type="date">. The intrinsic validator rejects anything that is not a real calendar date (2026-02-30 fails, not just malformed strings). Range constraints attach as the minDate / maxDate validation rules.

Country and state

country and state are fixed-option pickers: unlike select, the author never authors the choices, so neither carries a config at all. country offers every ISO 3166-1 alpha-2 country; state is scoped US-only (the 50 states plus DC), matching native Payload's own state block. Both store the code (US, CA) and format resolves it to the English name, and both are poll-eligible via resolveOptions, exposing the same fixed set as poll choices despite authoring no options of their own.

Because the code, not the resolved name, is what's stored, the email action's {{fieldName}} merge tag and its {{*}} / {{*:table}} wildcards (see Rich text bodies) always render the code: neither goes through a field type's format. The admin submission view and the plugin's own recall piping do go through it, so those two render the country or state name instead.

Both are opt-in: neither is registered by default, so add them through the registry. Each definition is exported for this:

payload.config.ts
import { formBuilder, countryField, stateField } from '@10x-media/form-builder'

formBuilder({
  fields: { country: countryField, state: stateField },
})

Repeater

The repeater type captures a dynamic list of rows, each containing the same set of sub-fields defined once in the admin UI. It is the right choice for anything list-shaped: team members, product variants, line items, references.

Admin config

OptionTypeDescription
minRowsnumberMinimum rows required. A zero-row submission is rejected when minRows > 0.
maxRowsnumberMaximum rows allowed.
addLabelstringLabel for the add-row button. Defaults to the built-in translation key.
subFieldsblocksThe fields inside each row. Authored in the admin add-field drawer; supports all non-repeater field types.

Validation

Row-count validation runs on the server for every submission. An empty repeater (no rows submitted) is still validated against minRows, so minRows: 2 correctly rejects a form submitted with zero rows. maxRows rejects arrays that are too long. Per-row sub-field validation (required checks, custom rules) runs after row-count validation; errors are reported with path fieldName[rowIndex].subFieldName.

Renderer

The built-in renderer renders each row in a <fieldset> with an add-row button and per-row remove buttons. It reads server-side sub-field errors from form state by composite key and displays them inline next to the offending input. Client-side sub-field validation also runs on submit, before the request is sent. To use a custom renderer, register one under the 'repeater' slug via the renderers prop on <Form>.

payload.config.ts
formBuilder({
  // Repeater is on by default; disable it like any other built-in
  fields: { repeater: false },
})

The registry

The fields option follows the plugin-wide opt-in convention: false removes a built-in, true keeps it, an object adds or replaces a type.

payload.config.ts
formBuilder({
  fields: {
    textarea: false,     // remove a built-in
    text: true,          // keep (explicitly)
    rating: myRating,    // add a custom type
  },
})

Defining a field type

payload.config.ts
import { formBuilder, defineFormField, type FieldTypeOption } from '@10x-media/form-builder'

export default buildConfig({
  plugins: [
    formBuilder({
      fields: {
        rating: defineFormField<'number'>({
          type: 'rating',
          label: 'Star rating',
          value: 'number',
          validate: ({ value }) => (value == null || value <= 5 ? true : 'Too high'),
          format: ({ value }) => `${value ?? 0} / 5`,
        }) as FieldTypeOption,
      },
    }),
  ],
})

value is the value kind ('text', 'number', 'boolean', and so on) and drives the TValue that validate and format receive. validate runs on the server for every submission, and on the client for UX when the renderer knows the type. format turns a stored value into display text; the admin answers view, recall tokens, and results all use it. Optional extras: config, a Payload Field[] rendered in the add-field drawer so your type gets admin-authorable settings; schema, a Standard Schema validator (see Validation); pollEligible, marking the type choosable as a poll's results field (its instances must produce enumerable answers, via authored options, a poll option source, or its own resolveOptions; built-in: select, plus country/state when opted in); resolveOptions, letting a pollEligible type source its poll choices from the configured instance itself (a hasMany relationship it holds, say) instead of from authored options or a source (see custom eligible field types); omitShared, leaving the shared basics your type never reads (label, placeholder, description, width, required) unauthored while keeping the rest of the chrome, as checkbox and calculation do (see Authoring layout); and bare, dropping the shared chrome so the block is your config alone, as the message type does.

bare requires value: 'none', and the plugin throws at boot otherwise: a bare instance is nameless, so a value-bearing one would have nowhere to store its answer and would silently drop it. Reach for it for display-only types; anything a visitor answers stays named.

Field definitions use precise generics, so cast each registry entry to FieldTypeOption at the registry boundary (the erased type the registry stores), exactly as the built-ins do.

To render a custom type on the frontend, register a matching renderer under the same slug: see Rendering.

Submissions are typed and self-describing

Each submission stores typed values plus a localized descriptor snapshot taken at submit time, all validated server-side. The snapshot means a submission stays readable even after the form's fields change: the admin answers view formats each value with the field type's format, falling back to the snapshot when a field has been removed or renamed.

Submissions live in the form-submissions collection: anonymous create (public forms must submit), read for logged-in users, update closed. See Submit contract for the full write path and the ways to reach it.

On this page