10x-media 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 (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.

Progressive validation

Validation runs on blur, re-validates on change once a field is touched, then validates all visible fields on submit. 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, or replace the transport entirely:

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

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.
onSubmitSubmitHandlernoneCustom transport.
onSuccess / onErrorcallbacksnoneSubmission outcome hooks.
eventsFormEventSinknoneLifecycle event sink; see Actions and events.
t / localetranslator / stringidentity / 'en'Localization for renderer strings and formatting.
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.
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 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
})

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.

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.

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