Customization
Collection overrides, submission admin UI options, and other plugin-level configuration.
Options API conventions
Every option follows one of a few shapes, and knowing which saves you from fighting the API:
- Opt-in flags take Payload's
false/true/ object form: omit orfalsedisables,trueenables with defaults, an object customizes.spam,poll, anduploadswork this way. - Presence-enabled features are the sanctioned exception: they have no
true, and supplying the value is the enable. They come in two shapes. Directreq-scoped resolvers:consent.sources,email.fromAddresses, andemail.departmentstake a function that resolves options for the current request. Registries of named sources:email.recipientSources,email.fromSources, andpoll.sourcestake a record of entries whose per-entryresolveruns when the stored value is used (at send time for the email sources, at render and validation for poll options).eventsfollows the same rule with its sink object. - Composition seams hand you the built defaults and take your result back, so you extend rather than replace:
buttons.fields,settings.fields,poll.outcomeFields,response.redirect.fields, and eachoverrides.*fieldsreceive the defaults for you to spread, wrap, reorder, or filter. - Registry seams key entries by slug and replace a matched entry wholesale:
fields,rules,actions, andpoll.types. To change one built-in and keep the rest, spread it from the exported defaults (see Tweaking one built-in).
One deliberate divergence is worth calling out. Consent stores only a source's row id and resolves the statement live per request, so editing a source reaches every form at once. Department routing does the opposite: email.departments resolves each department's address at author time and freezes the resolved address into the form's emailTeam action. That is intentional: a submission must route to the address the form was saved with, so the target stays audit-stable even if the department's address later changes.
Tweaking one built-in
The registries behind field types and validation rules replace a built-in wholesale when you pass an object under its type. To change one property of a built-in and keep the rest, spread it from the exported defaults: defaultFieldDefinitionsByType and defaultValidationRulesByType index the built-ins by type (the arrays defaultFieldDefinitions and defaultValidationRules are exported too).
import {
defaultFieldDefinitionsByType,
formBuilder,
type FieldTypeOption,
} from '@10x-media/form-builder'
formBuilder({
fields: {
text: {
...defaultFieldDefinitionsByType.text,
validate: ({ value }) =>
typeof value === 'string' && value.includes('@') ? 'No emails here' : true,
} as FieldTypeOption,
},
})The same pattern works for rules via defaultValidationRulesByType under the rules option.
Spread-overrides and localizeContent
The prebuilt default exports (defaultFieldDefinitionsByType, defaultActionDefinitions) are built with content localization on, so their content-bearing config fields carry localized: true. On a host running localizeContent: false, spreading one of them silently reintroduces the flag for that one type. Derive overrides from the builder functions instead, passing the same flag you gave the plugin:
import { buildDefaultFieldDefinitions, formBuilder, type FieldTypeOption } from '@10x-media/form-builder'
const [textDefault] = buildDefaultFieldDefinitions(false).filter((d) => d.type === 'text')
formBuilder({
localizeContent: false,
fields: {
text: { ...textDefault, validate: myValidate } as FieldTypeOption,
},
})buildDefaultActionDefinitions(localize) is the equivalent builder for actions. With the default localizeContent: true the prebuilt exports are exactly the builder output, so the shorter spread recipe above stays correct. Consent statements are not affected either way: they live on your own consent sources field, which carries its own localized option.
Rich text editors
The plugin authors rich text in three places: message content, the response message, and action bodies. None of them set an editor of their own, so all three inherit your config's editor, which is why the config must set one. Consent statements are a fourth, but they live on a field you place yourself, so consentSourcesField({ editor }) is what redirects those.
Two options redirect that:
| Option | Scope |
|---|---|
richText.editor | The default editor for every plugin rich text field |
richText.bodyEditor | The action body fields only; falls back to editor |
formBuilder({
richText: {
// Every plugin rich text field, instead of the project editor
editor: formCopyEditor,
// ...except action bodies, which get their own
bodyEditor: emailBodyEditor,
},
})Set neither and all three stay on the project editor. Set only editor to move the whole plugin, bodies included, onto one editor. Set only bodyEditor to single out email bodies and leave the rest on the project editor. The split exists because a form's on-page copy and an email's body land in different renderers: the features a consent statement wants are rarely the ones that survive an email client.
Submission answers UI
When you open a form-submissions document in the admin, the plugin renders a SubmissionAnswers component in place of the raw stored fields. Each answer renders as a native Payload field, the same TextInput/FieldLabel primitives Payload's own fields use, formatted through its field type's format function and laid out at its authored width so the admin view matches the widths editors configured on the form itself. Repeater rows render as numbered sub-sections; a file answer renders as a link to the stored upload; consent renders an Agreed/Declined Pill next to the source link and the capture timestamp. locale and the submission meta (received-at time, IP/user agent when captured, the spam signal) are folded into a single "Submission details" section at the bottom, rather than appearing as separate top-level fields.
The raw locale, values, descriptors, consent, and meta fields are hidden by default. They contain the same data as the formatted UI and are noisy alongside it. To show them (useful for debugging), set showSubmissionRawFields:
formBuilder({
showSubmissionRawFields: true,
})With this option off (the default), the answers UI field is the only visible representation of a submission's content in the admin. The raw fields still exist in the database and are returned by the API; only the admin display is affected.
Form-level buttons
Every form carries three localized button-label fields at the document root, not in a group: submitLabel at the bottom of the Fields tab, and nextLabel / prevLabel in a row at the bottom of the Flow tab (shown once the flow has at least one step). The rendered chrome resolves each label as <Form> prop, then the stored value, then the translated default.
Each slot is composable independently through the plugin's buttons.fields option: it receives the three default fields and returns a field for each slot, submit / prev / next, not an array. Wrap a default in a row with your own field, reorder within that row, or replace a default outright. Here the submit label gets an accompanying icon select:
formBuilder({
buttons: {
fields: ({ defaultFields }) => ({
submit: {
type: 'row',
fields: [
defaultFields.submit,
{
name: 'submitIcon',
type: 'select',
options: [
{ label: 'Arrow right', value: 'arrow-right' },
{ label: 'Paper plane', value: 'paper-plane' },
],
},
],
},
prev: defaultFields.prev,
next: defaultFields.next,
}),
},
})A field added this way is stored on the form document (submitIcon beside submitLabel), but it does not automatically reach the client. buttons, like poll, is a deliberately narrow allowlist on toFormDocument (unlike response, which is cast through wholesale), so admin-only config, or a poll source's secrets, can never leak to an anonymous visitor. Surface a host-added button field yourself by reading it off the raw fetched document and merging it into toFormDocument's output before handing it to <Form>:
import { toFormDocument } from '@10x-media/form-builder/rsc'
const raw = await payload.findByID({ collection: 'forms', id })
const doc = toFormDocument(raw)
doc.buttons = { ...doc.buttons, submitIcon: raw.submitIcon }Then read it back from the form context in a custom button renderer:
'use client'
import {
Form,
type FormDocument,
type SubmitButtonRenderProps,
useFormContext,
} from '@10x-media/form-builder/react'
const icons: Record<string, string> = { 'arrow-right': '→', 'paper-plane': '✈' }
const SubmitWithIcon = ({ label, submitting }: SubmitButtonRenderProps) => {
const { form } = useFormContext()
const icon = typeof form.buttons?.submitIcon === 'string' ? form.buttons.submitIcon : undefined
return (
<button type="submit" disabled={submitting}>
{icon ? <span aria-hidden>{icons[icon]}</span> : null}
{label}
</button>
)
}
export const ContactForm = ({ form }: { form: FormDocument }) => (
<Form form={form} renderSubmit={(props) => <SubmitWithIcon {...props} />} />
)renderNext and renderBack compose identically.
To assemble a slot from scratch instead of receiving the default, the individual builders are exported: buildSubmitLabelField(localize), buildNextLabelField(localize), and buildPrevLabelField(localize), plus buildDefaultButtonFields(localize) returning all three keyed submit/next/prev, exactly what buttons.fields is handed as defaultFields.
Form-level settings
Every form carries three behavior flags at the document root: multistep, pollEnabled, and persistSubmissions (default checked), rendered as sidebar checkboxes. The plugin's settings.fields option composes them, mirroring buttons.fields: it receives the three defaults keyed multistep / pollEnabled / persistSubmissions and returns the final field array placed verbatim at the collection root, so you can relocate a flag out of the sidebar, wrap it with a host field, reorder, or drop one.
Here persistSubmissions moves into the main area next to a host-added retention flag:
formBuilder({
settings: {
fields: ({ defaultFields }) => {
const { admin: _sidebar, ...persistSubmissions } = defaultFields.persistSubmissions
return [
defaultFields.multistep,
defaultFields.pollEnabled,
persistSubmissions, // now rendered in the main area, not the sidebar
{ name: 'anonymizeOnPrune', type: 'checkbox', label: 'Anonymize instead of deleting' },
]
},
},
})Omit the option and the three flags stay exactly where they are today: sidebar checkboxes, in that order. buildDefaultSettingsFields() is exported for assembling the trio from scratch.
List view
The forms list favors compact, localized summaries over Payload's defaults: the Fields column shows a count ("6 Fields") instead of listing every block type, and the Flow column shows a step count ("2 steps", or — with no flow) instead of raw JSON. Both cells read from the plugin's own translations.
The default column sets are curated rather than "every field": forms shows Title, Fields, Flow, Poll, and Updated At; form-submissions shows Form, Status, Locale, and Created At. Both are ordinary admin.defaultColumns and go through the standard collection overrides seam like anything else:
formBuilder({
overrides: {
forms: {
admin: { defaultColumns: ['title', 'pollEnabled', 'updatedAt'] },
},
},
})Poll outcome fields
The forms poll.outcome group holds a poll's recorded winners. Its winningValues multi-select is backed by the GET /api/forms/:id/poll-options endpoint (the poll's effective options) and stores a set, so a tie records every winner; resolvedAt is a server-stamped date locked against every caller write. Compose the group with the poll.outcomeFields option, which mirrors buttons.fields: it receives the two default fields and returns the group's final field array verbatim, so you can reorder, wrap, or swap the winner picker for a component of your own, for example a relationship select over the voteable records:
formBuilder({
poll: {
outcomeFields: ({ defaultFields }) => [
{
...defaultFields.winningValues,
admin: {
components: { Field: { path: './WinnerPicker#WinnerPicker' } },
},
},
defaultFields.resolvedAt,
],
},
})Keep the stored value a PollOption.value string whatever component renders it: pollOutcomeBeforeChange validates the winner against the poll's effective options server-side regardless of which field authored it, so a swap cannot bypass the membership check. Preserve defaultFields.resolvedAt (or rebuild the same field-level access lock) to keep the stamp tamper-proof. buildWinningValuesField(), buildResolvedAtField(), and buildDefaultOutcomeFields() are exported for assembling the group from scratch.
buildWinningValuesField() also carries a condition that shows the field only for a manual poll type, hiding it for mostVoted, source, and any host strategy alike: each of those sets the winner itself once the poll closes, so there is nothing for an editor to hand-pick. A swapped-in Field for that slot should read poll.type off the document and preserve the same hide, unless it deliberately wants a picker that stays visible regardless of strategy.
Redirect response fields
When an author sets the response to Redirect, the response.redirect group holds where to send the visitor: a url text field, plus a polymorphic reference relationship when redirectRelationships is set. Most projects already have a custom link field (choose a URL or pick a linkable document, like the rich-text link element), and the response.redirect.fields option lets you drop it in as a one-liner. It mirrors buttons.fields and poll.outcomeFields: you receive the default fields and return the group's final field array, so prepend your own link group, swap url for your picker, reorder, or filter:
formBuilder({
response: {
redirect: {
fields: ({ defaultFields }) => [myLinkGroup, ...defaultFields],
},
},
})Omit the option and the group stays the built-in url (plus reference when configured), unchanged.
The plugin's built-in redirect handling reads redirect.url and redirect.reference; toFormDocument passes the whole response group through, so anything you add here reaches the client on form.response.redirect. If you replace url with your own link field, your frontend owns resolving that field to a destination and writing it into response.redirect.url before render (or navigating from onSuccess), exactly as with the internal-reference case. <Form>'s automatic post-submit navigation only ever acts on response.redirect.url.
Collection overrides
The plugin manages two Payload collections, forms and form-submissions (plus the hidden form-poll-votes tally, overridden through poll.votes.overrides). Both accept an overrides option that lets you extend them in a way that is explicit and predictable, without using a deep-merge utility that can silently drop plugin-critical config. (The uploads collection needs no override seam: it is yours to begin with.)
Renaming collections
Every collection the plugin registers takes its slug from its override. Use this when a host already has a forms collection, or when the admin should say "Surveys" and the API should match:
formBuilder({
overrides: {
forms: { slug: 'surveys' },
formSubmissions: { slug: 'responses' },
},
poll: {
votes: { overrides: { slug: 'survey-tallies' } },
cookiePrefix: 'acme-voted-',
},
})The plugin resolves the slugs once at boot and uses them everywhere it touches its own collections: the submissions' relationship to their form, the post-submit actions job, pruning, the vote tally (including its raw Mongo and Postgres writes), poll results and outcomes, the notAlreadySubmitted rule, and the signed voted cookie. Two plugin collections resolving to the same slug is a boot error. poll.cookiePrefix is separate from the slugs: it renames the fb-voted-{formId} cookie, for hosts whose cookie names follow a convention or feed a consent tool.
The browser components cannot see your server config, so give <Form> the same slugs. The prop also reaches <Poll> and the shadcn registry form, which pass their props through:
<Form form={form} collections={{ forms: 'surveys', formSubmissions: 'responses' }} />A custom onSubmit receives the submissions slug as input.collection, so a handler that forwards its input to submitForm keeps working unchanged. The admin components already derive the slug from the document they are mounted in and need nothing.
Server code of your own reads the live slugs with collectionSlugsOf(payload) instead of hardcoding them. It returns the defaults when the plugin is not installed:
import { collectionSlugsOf } from '@10x-media/form-builder'
const { formSubmissions } = collectionSlugsOf(payload)
const recent = await payload.find({ collection: formSubmissions, limit: 10 })Renaming on a live database is a migration, not a config tweak. Existing rows stay under the old collection, and voted cookies issued before the change stop matching: a renamed cookie prefix is a different cookie, and a signed allow-change cookie names the collection it was issued for, so a re-vote after the rename counts as a new voter.
The spread convention
Each key in overrides is a CollectionOverrides object. The plugin builds the defaults, then merges them with your overrides using explicit spreads. The spread order encodes who wins:
- Keys where the consumer wins:
{ ...pluginDefault, ...(overrides?.key ?? {}) }, your value is last. - Keys the plugin locks:
{ ...(overrides?.key ?? {}), pluginLockedValue }, the plugin value is last and cannot be overwritten. - Hooks:
[...pluginHooks, ...(overrides?.hooks?.X ?? [])], the plugin's hooks always run first, your hooks are appended.
This means you can always add a beforeValidate hook to form-submissions and it will run after the spam guard and submission validator, not instead of them.
Fields as a function
The fields key is not an array; it is a function. You receive the plugin's default fields and return the final array:
import { formBuilder, type FieldsOverride } from '@10x-media/form-builder'
formBuilder({
overrides: {
formSubmissions: {
fields: ({ defaultFields }) => [
...defaultFields,
{ name: 'source', type: 'text' },
],
},
},
})This makes the intent explicit: you cannot accidentally wipe the defaults by forgetting to spread them. If you want to remove a field, .filter() it out. If you want to wrap or rename one, .map() over it.
Access control
Access keys are spread with the plugin's defaults first and your overrides last:
formBuilder({
overrides: {
forms: {
access: {
// The plugin sets read: () => true (forms are public). Override it:
read: ({ req }) => Boolean(req.user),
},
},
},
})Admin settings
formBuilder({
overrides: {
forms: {
admin: { group: 'Content' },
// Also valid: labels, defaultSort, pagination, etc.
},
},
})Adding hooks
formBuilder({
overrides: {
formSubmissions: {
hooks: {
afterChange: [
async ({ doc }) => {
// Runs after the plugin's own afterChange (action dispatch + event sink)
await notifySlack(doc)
},
],
},
},
},
})API reference
import type { CollectionOverrides, FieldsOverride } from '@10x-media/form-builder/types'
formBuilder({
overrides?: {
forms?: CollectionOverrides
formSubmissions?: CollectionOverrides
}
})CollectionOverrides is { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>>. Its slug renames the collection (see Renaming collections).
FieldsOverride is (args: { defaultFields: Field[] }) => Field[].