Plugins
Form Builder

Rendering

The headless Form controller, renderers, primitives, hooks, and the layout grid.

@10x-media/form-builder/react renders forms on your frontend. It is headless: accessible markup with stable class hooks, no imposed styles. Bring CSS, swap renderers, or drop to hooks for full markup control; see Styling for the three paths.

<Form>

import { Form } from '@10x-media/form-builder/react'

<Form
  form={form}
  onSuccess={(submissionId) => router.push('/thanks')}
  onError={(message) => console.error(message)}
/>

form is a FormDocument ({ id, fields, flow?, response?, buttons?, title?, multistep, pollEnabled, poll? }). Payload's generated types do not line up exactly (nullable groups, opaque JSON flow), so narrow the fetched document with toFormDocument instead of casting:

app/contact/page.tsx
import { toFormDocument } from '@10x-media/form-builder/rsc'

const doc = await payload.findByID({ collection: 'forms', id })
return <ContactForm form={toFormDocument(doc)} />

toFormDocument is framework-agnostic (no 'use client') and exported from the root, /rsc, and /react, so it runs in a Server Component before the document crosses to the client. It also strips server-only poll config (resultsField, source config) and takes a second argument for injecting request-resolved data: { pollOptions } for source-resolved poll options (from resolvePollOptions) and { consentStatements } for live consent statements (from resolveConsentStatements), since a form stores only a source id and resolves the sentence per request.

title is the form's admin title, passed through as a plain string. <Form> never renders it: whether and where a title appears above the fields is entirely up to your page, so render form.title yourself (or don't) alongside <Form>. multistep and pollEnabled mirror the form's two admin checkboxes; <Form> reads multistep (via flow) for step navigation, and a poll page typically renders <Poll> instead of <Form> when pollEnabled is true. response controls what a successful submit does: type message renders the authored rich text in place of the plain successMessage prop, its {{field}} / {{field||fallback}} tokens interpolated against the submission's own formatted answers (option labels, Yes/No, localized dates) exactly like an action body; type redirect navigates to the configured URL (a full document load by default; give your router control with adapters.navigate; part of submit handling, so it fires on the custom-children path too; see below for redirecting to an internal document instead). buttons carries the form-level submit/next/prev labels (see label precedence below); a host-added button field needs merging into toFormDocument's output by hand to reach the client, see Customization.

Redirecting to an internal document

Set the plugin's redirectRelationships option to let an author redirect to a document you manage instead of typing a URL:

payload.config.ts
formBuilder({ redirectRelationships: ['pages'] })

response.redirect then gains a polymorphic reference field alongside url. <Form> itself still only ever acts on response.redirect.url for its automatic post-submit navigation: the plugin has no notion of your routing, so toFormDocument passes reference straight through as Payload's own depth-0 { relationTo, value } pair (never a populated document), for you to resolve. Resolve it before rendering and write the result into response.redirect.url so <Form>'s built-in redirect picks it up:

app/contact/page.tsx
import { toFormDocument } from '@10x-media/form-builder/rsc'
import type { CollectionSlug } from 'payload'

const raw = await payload.findByID({ collection: 'forms', id })
const form = toFormDocument(raw)
const reference = form.response?.redirect?.reference
if (reference && form.response) {
  const target = await payload.findByID({
    collection: reference.relationTo as CollectionSlug,
    id: reference.value,
    depth: 0,
  })
  form.response.redirect = { url: `/${String(target.slug)}` } // your own routing
}

Skipping url and navigating yourself from onSuccess instead works too, if you'd rather not mutate the document before render. Leave redirectRelationships unset (the default) and response.redirect stays URL-only, exactly as before.

To drop in a link field you already have (choose a URL or pick a linkable document) rather than the plain url text field, compose the group with the response.redirect.fields seam. A field you replace url with is yours to resolve to a destination, the same as reference above.

Appending a field alongside the defaults is the common case, since reference and url still cover most redirects:

payload.config.ts
formBuilder({
  response: {
    redirect: {
      fields: ({ defaultFields }) => [...defaultFields, { name: 'anchor', type: 'text' }],
    },
  },
})

Host adapters

The React layer performs three effects a host framework usually owns: navigation, client persistence, and third-party script loading. The adapters prop overrides them, the same way onSubmit and fetchResultsImpl already override the network calls. Every member is optional and defaults to the current DOM behavior, so omitting the prop changes nothing:

type FormAdapters = {
  /** Replaces window.location for the post-submit redirect. */
  navigate?: (url: string, options: { replace: boolean }) => void
  /** Replaces the <Poll> localStorage voted flag; false disables client persistence. */
  voteStorage?: VoteStorage | false
  /** Replaces the captcha widgets' script injection (consent managers, CSP nonces). */
  loadScript?: (src: string) => Promise<void>
}

navigate is the one most hosts want. Without it, a redirect response hard-navigates via window.location.assign after onSuccess, so a router.push inside onSuccess starts a transition the full document load immediately discards. With it, the plugin calls your adapter instead and never touches window.location for that form: no fallback, no double navigation, and a throwing adapter propagates rather than degrading to a hard load. It fires after onSuccess and after the overlay dismissal under dismissOnSuccess, with the plugin's intent passed as options ({ replace: false } today: the built-in default keeps push semantics):

// Next.js App Router
const router = useRouter()
const adapters = useMemo<FormAdapters>(() => ({
  navigate: (url, { replace }) => (replace ? router.replace(url) : router.push(url)),
}), [router])

<Form form={form} adapters={adapters} />

The same shape covers TanStack Router (navigate({ to: url, replace })) and React Router (navigate(url, { replace })). Define the object once in a host module and pass it everywhere: <Poll> forwards adapters to its inner <Form>, and the captcha widgets accept it too. Pass a stable reference (module scope or useMemo), matching the other injectable props.

voteStorage is documented with polls, loadScript with the captcha widgets. The FormAdapters, VoteStorage, NavigateOptions, and LoadScript types are exported from @10x-media/form-builder/react.

Progressive validation

Validation reveals on blur only once a field is dirty (its value changed since mount or the last reset), re-validates on change once revealed, then validates all visible fields on submit. Focusing a field and leaving it untouched says nothing, and a successBehavior: 'reset' makes every field pristine again, so clicking around a just-submitted form never scolds. Hidden fields (visibleWhen) are excluded from validation and from the submitted values. Server-returned field errors map back to the correct fields.

Submission is also guarded against double activation: while a submit is in flight, a second click or an Enter-plus-click cannot reach the transport and POST twice. The disabled button is feedback on top of that guard, not the guard itself.

Transport

By default <Form> POSTs to {apiRoute}/form-submissions (default /api). Override the prefix with apiRoute, point it at a renamed submissions collection with collections, or replace the transport entirely (see Submission locale for the locale it receives):

<Form
  form={form}
  submissionLocale={contentLocale}
  onSubmit={async ({ formId, values, locale }) => {
    const res = await myClient.submitForm(formId, values, { locale })
    return res.ok ? { ok: true } : { ok: false, message: res.error }
  }}
/>

Submission locale

submissionLocale is the Payload content locale the visitor submits under, one of your localization locale codes (en, de). The request carries it as ?locale=, so the server stores it on the submission and the post-submit actions (the confirmation email included) load their subject, body, and recipients in it. A custom onSubmit receives it as locale. Without the prop the submission takes your default locale.

It is separate from locale on purpose. locale is a formatting tag handed to renderers and to each field type's format (often a full tag such as en-US for Intl), and it is never sent. A formatting tag is usually not a content locale code, so a site that renders en-US but stores content under en passes both:

<Form form={form} locale="en-US" submissionLocale="en" t={t} />

The server clamps what it receives: on a host with localization only a configured locale code is kept (all, *, and any other code become the default locale); without localization any valid language tag such as uk or pt-BR is kept in its canonical form (zh_Hant is stored as zh-Hant) and anything else becomes en.

The server reads the form in that locale exactly like any other Payload read, so content the author never filled in for it follows your localization.fallback: with fallback on (Payload's default) it comes from the default locale, or from that locale's own fallbackLocale; with fallback off it stays empty, the same as on your page. An email left with neither subject nor body then fails its action rather than being sent blank (see Email layout).

When the right fallback depends on the form rather than the config, for example a multi-tenant app whose tenants each have their own default locale, or a host that turned fallback off but still wants its emails to fall back, choose it per form with the fallbackLocale option. It applies wherever the server acts on a form: validating a submission, running its actions, serving poll results.

payload.config.ts
formBuilder({
  fallbackLocale: async ({ form, payload }) => {
    const tenant = await payload.findByID({
      collection: 'tenants', id: form.tenant as string, depth: 0, overrideAccess: true,
    })
    return tenant?.defaultLocale
  },
})

It receives the form as already read with your config's fallback, at depth 0, so a non-localized owner relationship is on it without a read of your own, plus the locale it is being read at, payload, and req. Decide from non-localized fields: a localized one (the title, a label) may be empty at that locale, which is the case the resolver exists for. Return a locale code, an ordered list of codes, false for no fallback, or undefined to keep the default. The form is read a second time only when the result differs from the fallback already applied, so a tenant writing in its own default locale costs nothing extra. To force the default locale everywhere on a host with fallback off, return it unconditionally (fallbackLocale: () => 'en'). The resolver runs on every server-side read of a form, so cache what it looks up if that is expensive. A throw is never swallowed: it rejects the submission, fails the action run (retried on the queued path), or fails the results read. Render the form on your page with the same fallback, so what the visitor sees is what the server validates and sends.

Props

PropTypeDefaultDescription
formFormDocumentrequiredThe form document.
renderersRenderersConfigbuilt-insRenderer overrides (false/true/component per type).
fieldTypesAnyFormFieldDefinition[]noneExtra field-type definitions, for custom types.
rulesAnyValidationRuleDefinition[]noneExtra validation rule definitions.
apiRoutestring'/api'API prefix for the built-in transport.
collectionsFormCollectionsdefaultsRenamed plugin collections ({ forms?, formSubmissions? }), matching the server's overrides.*.slug.
onSubmitSubmitHandlernoneCustom transport.
onSuccess / onErrorcallbacksnoneSubmission outcome hooks.
eventsFormEventSinknoneLifecycle event sink; see Actions and events.
t / localetranslator / stringidentity / 'en'Localization for renderer strings and formatting.
submissionLocalestringnoneContent locale the submission is stored and its emails are rendered in; see Submission locale.
layoutbooleantrueDisable the grid wrapper with false.
classNamestringnoneExtra CSS classes on the root <form> element (and the post-submit success node).
submitLabel, nextLabel, prevLabel, closeLabelstringbuilt-insButton labels. Submit, next, and prev each resolve prop, then the form's buttons value, then the translated default.
submitButtonClassNamestringnoneCSS class on the default submit button. Ignored when renderSubmit is provided.
nextButtonClassNamestringnoneCSS class on the default Next button (multi-step). Ignored when renderNext is provided.
backButtonClassNamestringnoneCSS class on the default Back button (multi-step). Ignored when renderBack is provided.
renderSubmit(props: SubmitButtonRenderProps) => ReactNodenoneReplace the default submit button entirely.
renderNext(props: NextButtonRenderProps) => ReactNodenoneReplace the default Next button in multi-step forms.
renderBack(props: BackButtonRenderProps) => ReactNodenoneReplace the default Back button in multi-step forms.
successMessagestring'Thank you.'Post-submit message (supports recall tokens).
presentation, presentations, onClose, titleOverlay control; see Presentations.
initialValuesRecord<string, unknown>noneSeed values; see Prefill.
honeypot, captchaTokendefaults on / noneSee Spam.
adaptersFormAdaptersDOM behaviorHost-owned effects (navigation, vote persistence, script loading); see Host adapters.
childrenReactNodenoneCustom layout mode; see below.

A repeater field with minRows starts pre-seeded with that many empty rows, matching the schema's floor. An explicit initialValues entry for the field (including an empty array) wins over the seed.

Form chrome and markup

The chrome below the fields is a .fb-form__controls wrapper containing the default buttons, each with a stable class: fb-form__back, fb-form__next, fb-form__submit. The root form is fb-form-root; the post-submit node is fb-form__success, and a submit failure renders in fb-form__submit-error. The chrome itself is the exported <FormControls> component (below), so custom layouts get the identical behavior.

Custom buttons

Use render props to replace a button with your own component while keeping the built-in label, state, and click wiring:

<Form
  form={form}
  renderSubmit={({ label, submitting }) => (
    <button type="submit" className="btn btn-primary" disabled={submitting}>
      {submitting ? 'Sending…' : label}
    </button>
  )}
  renderBack={({ label, onClick }) => (
    <button type="button" className="btn btn-ghost" onClick={onClick}>{label}</button>
  )}
/>

For simple styling without replacing markup, pass submitButtonClassName, nextButtonClassName, or backButtonClassName instead:

<Form
  form={form}
  submitButtonClassName="btn btn-primary"
  nextButtonClassName="btn btn-secondary"
  backButtonClassName="btn btn-ghost"
/>

The render prop types (SubmitButtonRenderProps, NextButtonRenderProps, BackButtonRenderProps) are exported from @10x-media/form-builder/react.

Submit contract

A submission is POST {apiRoute}/form-submissions (optionally ?locale=, stored as the submission's locale) with a JSON body of { form, values }, where values is an array of { field, value } pairs (SubmissionValue, exported from /types). <Form>'s built-in transport above is one way to reach that endpoint, not the only one: every rule this page and Validation describe (server-authoritative validation, spam checks, descriptor snapshotting, consent capture, post-submit action dispatch) runs in the form-submissions collection's own hooks, not in a route handler <Form> calls into. Three front doors reach the identical pipeline:

  • The browser submitForm(input) helper (@10x-media/form-builder/react), which <Form> calls internally.
  • A raw fetch/REST POST to {apiRoute}/form-submissions from any HTTP client.
  • Server code calling payload.create({ collection: 'form-submissions', data: { form, values } }), or the typed createSubmission helper that wraps the identical call:
import { createSubmission } from '@10x-media/form-builder'

const submission = await createSubmission(payload, {
  form: formId,
  values: [{ field: 'email', value: 'ada@example.com' }],
  req, // thread the incoming req when there is one
  locale: 'de', // optional: the submission locale, as `submissionLocale` sets it in the browser
})

createSubmission exists for parity, not new behavior: a script, a queued job, or another collection's hook gets a documented, typed entry point instead of hand-rolling the collection slug and body shape. Pass the incoming req when you have one so spam metadata (client IP, user agent) and per-identity rate limiting see the real request; without one the submission still runs the full pipeline, just with no request to read those signals from. locale is clamped exactly like a browser's ?locale=; absent, the submission takes req.locale (or your default locale). The req you pass comes back with its own locale unchanged.

form-submissions access is create: () => true (anonymous submissions are the point of a public form), read is logged-in users only, and update is disabled entirely, so nothing about a submission is readable or editable through the API by the visitor who sent it. This mirrors Payload's own @payloadcms/plugin-form-builder: an open create endpoint backed entirely by collection hooks, not a custom controller in front of it.

Renderers

A renderer maps FieldRendererProps to markup for one field type. defineFieldRenderer pins the value type:

import { defineFieldRenderer } from '@10x-media/form-builder/react'

const myText = defineFieldRenderer<string>(
  ({ id, name, value, onChange, onBlur, errors, required, disabled }) => (
    <input
      id={id}
      name={name}
      value={value ?? ''}
      onChange={(e) => onChange(e.target.value)}
      onBlur={onBlur}
      required={required}
      disabled={disabled}
      aria-invalid={errors.length > 0 || undefined}
    />
  )
)

FieldRendererProps<TValue> carries the field instance, a stable id, the machine name, value, onChange/onBlur, errors, required, disabled, locale, and a t translator.

resolveRenderers merges the defaults with overrides using the opt-in convention:

import { defaultRenderers, resolveRenderers } from '@10x-media/form-builder/react'

const registry = resolveRenderers(defaultRenderers, {
  text: myText,           // replace a built-in
  number: false,          // remove one
  rating: ratingRenderer, // add a custom type
})

Pass the result (or just the overrides object) to <Form renderers={...}>.

Primitives

Unstyled, accessible control primitives for building custom renderers: Input, Textarea, Select, Checkbox, and FieldShell. FieldShell renders the label, the control slot, and the description/error region with correct aria-describedby, role="alert", and aria-invalid wiring; the built-in renderers are exactly these primitives composed.

If you write renderers from scratch instead, uphold the a11y contract: a programmatically associated label, aria-invalid only when invalid, aria-describedby pointing at an error region with role="alert" and aria-atomic, an aria-hidden required indicator, and native required on the control.

A custom renderer also replaces the built-in wholesale, which means every per-instance authoring setting the built-in honors becomes your renderer's job. Read them off the field instance, or an author's choice is silently ignored: field.display (select dropdown | radio | buttons, checkbox checkbox | switch; the ChoiceGroup primitive covers the radio and buttons variants), field.autocomplete on text and email, and a calculation's decimals/prefix/suffix via formatCalcValue. New instance settings ship in minor releases, so after a bump, diff your custom renderers against the built-ins for settings they gained.

Layout grid

An optional container-query grid ships in the stylesheet:

import '@10x-media/form-builder/styles.css'
import { FormLayout, widthProps } from '@10x-media/form-builder/react'

<FormLayout>
  <div {...widthProps('half')}>{/* first name */}</div>
  <div {...widthProps('half')}>{/* last name */}</div>
  <div {...widthProps('full')}>{/* message */}</div>
</FormLayout>

Widths: full, half, third, twoThirds. Skip the stylesheet (or pass layout={false}) for your own layout system.

Custom layouts with hooks

Pass children to <Form> and it renders them inside its context instead of the auto-rendered field loop, so you own the entire layout while <Form> keeps owning state and submission:

import { Form, FormControls, useField } from '@10x-media/form-builder/react'

function NameField() {
  const { value, errors, setValue, onBlur } = useField<string>('name')
  return (
    <label>
      Name
      <input value={value ?? ''} onChange={(e) => setValue(e.target.value)} onBlur={onBlur} />
      {errors.map((e) => <span key={e}>{e}</span>)}
    </label>
  )
}

<Form form={form}>
  <NameField />
  <FormControls />
</Form>

<FormControls> is the same chrome the default layout renders: back on non-first steps, next on non-terminal steps, submit on the terminal step, with labels resolved from context (props, the form's buttons labels, translated defaults). It accepts the same per-button className and render* overrides as <Form>, so a custom layout keeps the built-in step logic and double-submit guard instead of hand-wiring buttons. <FormSteps> composes the same way for step progress.

  • useField(name) returns value, errors, touched, setValue, onBlur.
  • useFormState() returns the whole FormState: values, errors, touched, submitting, submitted, submitAttempted, submitError.
  • useFormStep() drives multi-step UIs.
  • useFormContext() returns the full controller context the focused hooks read from: the rendered form document itself, state, dispatch, validateField, locale, step, rendererRegistry, the resolved chrome labels, the active translator t, and effectiveValues (answers overlaid with derived calc values). Reach for it when building renderer-like components (a custom repeater looking up sub-renderers, chrome that needs the resolved labels, a custom button reading host-added buttons keys off form); the focused hooks stay the ergonomic default.

On this page