10x-media plugins
Form Builder

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 or false disables, true enables with defaults, an object customizes. spam, poll, and uploads work this way.
  • Resolver-gated features are the sanctioned exception: they have no true and are enabled purely by supplying a req-scoped resolver. consent.sources, email.fromAddresses, email.departments, poll.sources, and events stay off until you pass the function, and passing it is the enable.
  • Composition seams hand you the built defaults and take your result back, so you extend rather than replace: buttons.fields, poll.outcomeFields, response.redirect.fields, and each overrides.* fields receive 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, and poll.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).

payload.config.ts
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:

payload.config.ts
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:

OptionScope
richText.editorThe default editor for every plugin rich text field
richText.bodyEditorThe action body fields only; falls back to editor
payload.config.ts
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:

payload.config.ts
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:

payload.config.ts
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>:

app/contact/page.tsx
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:

submit-with-icon.tsx
'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.

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:

payload.config.ts
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:

payload.config.ts
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:

payload.config.ts
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. 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.)

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:

payload.config.ts
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'>>.

FieldsOverride is (args: { defaultFields: Field[] }) => Field[].

On this page