Forms
Forms are powered by the @payloadcms/plugin-form-builder plugin on the backend and a small, extendable renderer on the frontend. Editors build forms in the admin panel; the frontend reads that definition, builds a zod schema from it, and wires up validation with react-hook-form.
The renderer is built around a field registry — adding a new field type is a self-contained change that touches no rendering logic.
How it works
Section titled “How it works”| File | Role |
|---|---|
src/plugins/payload-form-builder.ts | Plugin config — which field types are enabled, admin overrides |
src/components/form/form.tsx | Client component that renders a form and handles submission |
src/components/form/registry.tsx | Maps each field type to its component, schema, and default value |
src/components/form/fields/*-field.tsx | One renderer per field type |
src/components/form/build-schema.ts | Folds the form’s fields into a zod object schema |
src/components/form/get-form.ts | Server helper to fetch a form by id |
The plugin stores the form definition (fields, confirmation behaviour, emails). build-schema reads those fields and asks the registry for each field’s zod schema. Form feeds that schema to react-hook-form’s zodResolver, renders each field via the registry, and POSTs the result to /api/form-submissions.
Installing
Section titled “Installing”Forms are added (and removed) with the CLI feature command.
-
Add the feature — copies the form files into
src/, adds the dependencies (@payloadcms/plugin-form-builder,react-hook-form,@hookform/resolvers), and registers the plugin insrc/plugins/plugins.ts:Terminal window ddev pnpm cli feature add forms -
Install dependencies:
Terminal window ddev pnpm install -
Generate types — registers the
formsandform-submissionscollections:Terminal window ddev pnpm generate:types -
Create and run a migration for the new tables:
Terminal window ddev pnpm payload migrate:createddev pnpm payload migrate
To remove forms again — deleting the files, dependencies, and plugin registration — run ddev pnpm cli feature remove forms.
Rendering a form
Section titled “Rendering a form”Fetch the form server-side with getForm and pass it to <Form />:
import { Form, getForm } from '@/components/form';
export default async function ContactPage() { const form = await getForm(1); // form id
if (!form) { return null; }
return <Form form={form} />;}Form is a client component; getForm is server-only. It handles validation, the confirmation message, and redirects automatically based on the form’s settings.
Validation
Section titled “Validation”Each field contributes its own zod schema, so validation is derived entirely from the admin-configured form. Required fields, email format, and number bounds are enforced both in the browser and against the submitted shape.
Sane upper bounds guard against oversized submissions. They live as constants at the top of registry.tsx:
| Field | Limit |
|---|---|
text | 256 characters |
textarea | 5000 characters |
email | 254 characters (RFC 5321) |
number | ±1,000,000,000 |
Adding a new field type
Section titled “Adding a new field type”This is the core extension point. Three steps, no changes to Form or build-schema.
-
Enable the field in the plugin so editors can add it:
src/plugins/payload-form-builder.ts fields: {// ...date: true,}, -
Create the renderer. Field components pull react-hook-form’s context themselves via
useFormContext, so they receive only the field config:src/components/form/fields/date-field.tsx 'use client';import { useFormContext } from 'react-hook-form';import { FieldWrapper } from '../field-wrapper';export function DateFieldComponent({ field }) {const { register } = useFormContext();return (<FieldWrappername={field.name}label={field.label}required={field.required}width={field.width}><inputid={field.name}type="date"className="form-field__input"{...register(field.name)}/></FieldWrapper>);} -
Register it.
buildSchemareturns the field’s zod schema;getDefaultValueseeds the initial form value. OmitbuildSchemafor presentational fields (likemessage) to exclude them from validation and submission:src/components/form/registry.tsx date: defineField({Component: DateFieldComponent,buildSchema: (field) =>field.required? z.string().min(1, REQUIRED_MESSAGE): z.string().optional(),getDefaultValue: (field) => field.defaultValue ?? '',}),
That’s it — the form picks up the new type automatically.
Submissions
Section titled “Submissions”On submit, values are mapped to the plugin’s submissionData shape and POSTed to /api/form-submissions. Submissions appear in the admin under the Form Submissions collection.