Skip to content

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.

FileRole
src/plugins/payload-form-builder.tsPlugin config — which field types are enabled, admin overrides
src/components/form/form.tsxClient component that renders a form and handles submission
src/components/form/registry.tsxMaps each field type to its component, schema, and default value
src/components/form/fields/*-field.tsxOne renderer per field type
src/components/form/build-schema.tsFolds the form’s fields into a zod object schema
src/components/form/get-form.tsServer 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.

Forms are added (and removed) with the CLI feature command.

  1. 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 in src/plugins/plugins.ts:

    Terminal window
    ddev pnpm cli feature add forms
  2. Install dependencies:

    Terminal window
    ddev pnpm install
  3. Generate types — registers the forms and form-submissions collections:

    Terminal window
    ddev pnpm generate:types
  4. Create and run a migration for the new tables:

    Terminal window
    ddev pnpm payload migrate:create
    ddev pnpm payload migrate

To remove forms again — deleting the files, dependencies, and plugin registration — run ddev pnpm cli feature remove forms.

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.

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:

FieldLimit
text256 characters
textarea5000 characters
email254 characters (RFC 5321)
number±1,000,000,000

This is the core extension point. Three steps, no changes to Form or build-schema.

  1. Enable the field in the plugin so editors can add it:

    src/plugins/payload-form-builder.ts
    fields: {
    // ...
    date: true,
    },
  2. 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 (
    <FieldWrapper
    name={field.name}
    label={field.label}
    required={field.required}
    width={field.width}
    >
    <input
    id={field.name}
    type="date"
    className="form-field__input"
    {...register(field.name)}
    />
    </FieldWrapper>
    );
    }
  3. Register it. buildSchema returns the field’s zod schema; getDefaultValue seeds the initial form value. Omit buildSchema for presentational fields (like message) 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.

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.