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:
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:
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:
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
| Prop | Type | Default | Description |
|---|---|---|---|
form | FormDocument | required | The form document. |
renderers | RenderersConfig | built-ins | Renderer overrides (false/true/component per type). |
fieldTypes | AnyFormFieldDefinition[] | none | Extra field-type definitions, for custom types. |
rules | AnyValidationRuleDefinition[] | none | Extra validation rule definitions. |
apiRoute | string | '/api' | API prefix for the built-in transport. |
onSubmit | SubmitHandler | none | Custom transport. |
onSuccess / onError | callbacks | none | Submission outcome hooks. |
events | FormEventSink | none | Lifecycle event sink; see Actions and events. |
t / locale | translator / string | identity / 'en' | Localization for renderer strings and formatting. |
layout | boolean | true | Disable the grid wrapper with false. |
className | string | none | Extra CSS classes on the root <form> element (and the post-submit success node). |
submitLabel, nextLabel, prevLabel, closeLabel | string | built-ins | Button labels. Submit, next, and prev each resolve prop, then the form's buttons value, then the translated default. |
submitButtonClassName | string | none | CSS class on the default submit button. Ignored when renderSubmit is provided. |
nextButtonClassName | string | none | CSS class on the default Next button (multi-step). Ignored when renderNext is provided. |
backButtonClassName | string | none | CSS class on the default Back button (multi-step). Ignored when renderBack is provided. |
renderSubmit | (props: SubmitButtonRenderProps) => ReactNode | none | Replace the default submit button entirely. |
renderNext | (props: NextButtonRenderProps) => ReactNode | none | Replace the default Next button in multi-step forms. |
renderBack | (props: BackButtonRenderProps) => ReactNode | none | Replace the default Back button in multi-step forms. |
successMessage | string | 'Thank you.' | Post-submit message (supports recall tokens). |
presentation, presentations, onClose, title | Overlay control; see Presentations. | ||
initialValues | Record<string, unknown> | none | Seed values; see Prefill. |
honeypot, captchaToken | defaults on / none | See Spam. | |
children | ReactNode | none | Custom 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/RESTPOSTto{apiRoute}/form-submissionsfrom any HTTP client. - Server code calling
payload.create({ collection: 'form-submissions', data: { form, values } }), or the typedcreateSubmissionhelper 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)returnsvalue,errors,touched,setValue,onBlur.useFormState()returns the wholeFormState:values,errors,touched,submitting,submitted,submitAttempted,submitError.useFormStep()drives multi-step UIs.useFormContext()returns the full controller context the focused hooks read from: the renderedformdocument itself,state,dispatch,validateField,locale,step,rendererRegistry, the resolved chromelabels, the active translatort, andeffectiveValues(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-addedbuttonskeys offform); the focused hooks stay the ergonomic default.