WABBIT · ESSAYS

Payload form builder done right: spam, errors and receipts

Payload's form builder plugin takes a submission from anyone, checks almost nothing about the fields, and emails HTML only. Here's how we close each gap in Payload 3 + Next.js, from the error a screen reader reads out to the email the sender gets back.

  • Payload 3.90 · Next.js
  • 8 steps
  • 16 min read
Built on
Payload 3.90, @payloadcms/plugin-form-builder 3.90, Next.js 16 App Router, MongoDB
Starts from
Payload's website template, or any Payload 3 + Next.js app using the form builder plugin
Steps
8, about 16 min read
Files you'll touch
14 files, listed per step
  • src/blocks/Form/Error/index.tsx
  • src/blocks/Form/Text/index.tsx
  • src/blocks/Form/Required/index.tsx
  • src/blocks/Form/Component.tsx
  • src/components/FormConfirmation/index.tsx
  • src/plugins/index.ts
  • src/hooks/verifyFormSubmission.ts
  • src/utilities/turnstile.ts
  • src/utilities/formRateLimit.ts
  • src/utilities/validateSubmissionData.ts
  • src/hooks/sendSubmissionConfirmation.ts
  • src/email/templates/confirmation.ts
  • src/lib/forms/submissionRetention.ts
  • src/app/api/cron/submission-retention/route.ts

The form builder plugin gets you from zero to a working contact form fast. Editors build forms in the admin, the website template renders them, and submissions land in a form-submissions collection. What it leaves to you is everything that happens at the edges.

Look at the collection the plugin generates and three things stand out. Create access is () => true, which Payload's docs state as intent: "anyone should be able to create a form submission, even from a public-facing website." The value of each submitted field is validated only for !== undefined, and the plugin's own source carries a TODO saying real validation would need a collection beforeValidate hook (a request to wire up submission validations was closed as not planned). And the plugin's email hook builds html from the form's rich text and nothing else, so there's no plain-text part. A thread about the plugin sending email for submissions that should have failed shows how these gaps feel from the outside.

This walkthrough fixes all of that in eight steps. The code comes from the forms on wabbit.com, trimmed for a generic Payload 3 + Next.js project. Steps 1 and 2 make the form usable by everyone, steps 3 to 6 keep bots and malformed posts out on the server, and steps 7 and 8 cover what happens to a submission after it's stored.

Step 1: Tie every error to its field

The website template's field components get the basics right: a visible <Label htmlFor> and a screen-reader-only "(required)". The error message is where they fall short. It's a plain div with no id, the input doesn't point at it, and nothing tells assistive tech the field is invalid. A sighted user sees red text under the field. A screen reader user hears nothing until they tab back through the form.

Two small helpers fix it for every field type at once:

src/blocks/Form/Error/index.tsxTSX

The error gets an id; every input points at it and says when it's invalid.

'use client'
import { useFormContext } from 'react-hook-form'

export const errorId = (name: string) =>
  `${name}-error`

export const fieldA11y = (
  name: string,
  errors: Record<string, unknown>,
  required?: boolean | null,
) => ({
  'aria-invalid': !!errors[name],
  'aria-required': required ? true : undefined,
  'aria-describedby': errors[name]
    ? errorId(name)
    : undefined,
})

export const Error = ({ name }: { name: string }) => {
  const {
    formState: { errors },
  } = useFormContext()
  const message = errors[name]?.message
  return (
    <div id={errorId(name)} role="alert">
      {typeof message === 'string' && message
        ? message
        : 'This field is required'}
    </div>
  )
}

aria-describedby makes the error part of the field's description, so it's read whenever the field gets focus. aria-invalid is what WCAG technique ARIA21 uses to meet 3.3.1 Error Identification, and role="alert" on the message is ARIA19. Spread the helper onto each input, before register:

src/blocks/Form/Text/index.tsxTSX · excerpt

The same two lines go into Email, Textarea, Select, Checkbox and the rest.

<Label htmlFor={name}>
  {label}
  {required && <Required />}
</Label>
<Input
  defaultValue={defaultValue}
  id={name}
  type="text"
  {...fieldA11y(name, errors, required)}
  {...register(name, { required })}
/>
{errors[name] && <Error name={name} />}

For the shadcn Select, spread it onto SelectTrigger (the element that takes focus) and pass the ref from react-hook-form's Controller to it too.

The required marker needs one more change. The template renders a bare * next to "(required)", so a screen reader announces "star, required". Hide the asterisk from assistive tech and keep the words:

src/blocks/Form/Required/index.tsxTSX

The asterisk is visual only; the words are for screen readers.

export const Required = () => (
  <span className="required">
    <span aria-hidden="true">*</span>{' '}
    <span className="sr-only">(required)</span>
  </span>
)

Sighted users still need to know what the asterisk means. WCAG technique H90 says its meaning should be defined at the start of the form, before the first field that uses it, so we render a one-line "* required" at the top of any form that has a required field (it's in the step 2 code).

Focus is already handled if you use react-hook-form. Its shouldFocusError option defaults to true and moves focus to the first invalid field when a submit fails validation, as long as the field's ref reaches a real DOM element. That's why the Select trigger needs the ref: without it, the first invalid field can be skipped.

Step 2: Announce the result, and restate what was sent

A submit has two outcomes the visitor can't see unless something tells them: an error from the server, or success. WCAG 4.1.3 Status Messages asks for both to be exposed without moving focus, and its Understanding page maps errors to role="alert" and success messages to role="status".

The template replaces the form with the CMS confirmation message. We keep that message and add a receipt under it: what the visitor asked for, the address the reply goes to, and when to expect it. "Thanks, we'll be in touch" tells nobody what was sent, and that includes an AI agent filling in the form for someone and reporting back.

src/blocks/Form/Component.tsxTSX · excerpt

Error is an alert; the receipt lands in a status region that's already on the page.

{error && <div role="alert">{error}</div>}
<div role="status">
  {submitted && (
    <FormConfirmation receipt={receipt}>
      <RichText data={confirmationMessage} />
    </FormConfirmation>
  )}
</div>
{!submitted && (
  <form id={formID} onSubmit={handleSubmit(onSubmit)}>
    {hasRequiredField && <p>* required</p>}
    {/* fields, honeypot, Turnstile, button */}
  </form>
)}

The status wrapper stays on the page and the receipt is placed inside it. Screen readers announce changes to a live region they already know about. A region that arrives already filled is announced by some combinations of browser and screen reader and missed by others.

The receipt is a short definition list built from the submitted values and the form's settings:

src/components/FormConfirmation/index.tsxTSX

Request, reply address and reply time, in plain text.

type Receipt = {
  request: string
  email?: string
  replyTime?: string
}

export function FormConfirmation({
  receipt,
  children,
}: {
  receipt: Receipt
  children?: React.ReactNode
}) {
  return (
    <div>
      {children}
      <dl>
        <dt>You asked for</dt>
        <dd>{receipt.request}</dd>
        {receipt.email && (
          <>
            <dt>We’ll reply to</dt>
            <dd>{receipt.email}</dd>
          </>
        )}
        {receipt.replyTime && (
          <>
            <dt>When</dt>
            <dd>{receipt.replyTime}</dd>
          </>
        )}
      </dl>
    </div>
  )
}

Where request comes from is up to you. Ours is a select field on the form config (fit call, mockup request and so on) that maps to a short label, and the confirmation email in step 7 reads the same mapping so the page and the inbox say the same thing.

Step 3: Put the spam gate on the server, in beforeValidate

A CAPTCHA widget on the page doesn't protect anything by itself. Create access on form-submissions is public, so a bot can skip your page and POST JSON straight to /api/form-submissions. Cloudflare's own docs say it directly: "The client-side widget alone does not protect your forms" (server-side validation).

The plugin lets you add hooks to the collection it generates through formSubmissionOverrides. Your hooks are merged in, and the plugin puts its own email hook first in afterChange:

src/plugins/index.tsTypeScript · excerpt

One beforeValidate gate, one afterChange email.

formBuilderPlugin({
  formSubmissionOverrides: {
    hooks: {
      beforeValidate: [verifyFormSubmission],
      afterChange: [sendSubmissionConfirmation],
    },
  },
}),

Why beforeValidate and not beforeChange: it runs first, before field validation and before anything is written, so a rejected submission costs one hook call and leaves no row behind. It also runs once per create. In Payload 3.90's create operation (payload/dist/collections/operations/create.js), the collection's beforeValidate hooks are called in a single loop, and the plugin never updates a submission after creating it. That matters because a Turnstile token can be redeemed only once, so a hook that ran twice would reject its own second call. We have a test for it: one create, one siteverify call.

The hook runs five checks, cheapest first:

src/hooks/verifyFormSubmission.tsTypeScript

Honeypot, fill time, rate limit, Turnstile, then field checks.

import type { CollectionBeforeValidateHook } from
  'payload'
import { APIError } from 'payload'
import type { MongooseAdapter } from
  '@payloadcms/db-mongodb'
import { verifyTurnstile } from
  '@/utilities/turnstile'
import { clientIp, isRateLimited } from
  '@/utilities/formRateLimit'

const REJECT =
  'We couldn’t verify your submission. ' +
  'Please refresh the page and try again.'
const MIN_ELAPSED_MS = 2000

export const verifyFormSubmission:
  CollectionBeforeValidateHook = async ({
    data,
    operation,
    req,
  }) => {
  // Public creates only. Admin users and
  // updates pass straight through.
  if (operation !== 'create' || req.user) return

  const h = req.headers
  const ip = clientIp(h)

  // 1. Honeypot: a hidden field only bots fill.
  if ((h.get('x-hp') ?? '').trim() !== '') {
    throw new APIError(REJECT, 403)
  }

  // 2. Faster than a person can type.
  const elapsed = Number(h.get('x-elapsed') ?? 0)
  if (elapsed > 0 && elapsed < MIN_ELAPSED_MS) {
    throw new APIError(REJECT, 403)
  }

  // 3. Per-IP rate limit (fails open).
  const db = req.payload.db as MongooseAdapter
  if (await isRateLimited(db, ip)) {
    throw new APIError(
      'Too many submissions. Please wait a few ' +
        'minutes and try again.',
      429,
    )
  }

  // 4. Turnstile siteverify (fails closed).
  const token = h.get('cf-turnstile-response')
  const ts = await verifyTurnstile(token, ip, 'form')
  if (!ts.skipped && !ts.ok) {
    throw new APIError(REJECT, 403)
  }

  // 5. Field checks against the form: step 6.
}

The order follows cost. The honeypot and timing checks read a header. The rate limit is one database write. Siteverify is a network round trip to Cloudflare, so it goes after the cheap checks have thrown out the obvious junk. Field validation goes last on purpose: it loads the form and returns messages that name its fields, and there's no reason to describe a form's configuration to a client that hasn't passed Turnstile yet.

The client sends the honeypot value and the time since the form mounted as headers. Both are easy to fake, which is fine: they're there to catch the naive bots that fill every input and submit instantly. A missing x-elapsed passes the timing check, so a script that posts straight to the API skips checks 1 and 2 entirely. Check 4 is the one it can't skip.

Every bot-shaped rejection returns the same message. Telling a bot which check caught it tells it what to change. The same reasoning covers a question that comes up about honeypots: why not return a fake success, so the bot thinks it worked? We weighed it and kept the error. A "successful" honeypot hit has to go somewhere. If the hook lets it through, the plugin stores the row and its afterChange hook sends whatever emails the form is configured to send. And a bot posting straight to the API learns nothing useful from either answer, because nobody is reading the response.

APIError with a 4xx status is public by default in Payload 3, so the message reaches the client as errors[0].message in the JSON response. A 500 is hidden.

Step 4: Verify Turnstile properly

Siteverify is one POST, and most sample code stops there. Ours handles the cases that sample code skips: a slow or failed request, a token minted for a different form or site, and the difference between "Cloudflare said no" and "we couldn't reach Cloudflare".

src/utilities/turnstile.tsTypeScript

Fails closed, 4-second timeout, one retry under the same idempotency key.

const SECRET = process.env.TURNSTILE_SECRET_KEY
const HOSTS = new Set(
  (process.env.TURNSTILE_HOSTNAMES ?? '')
    .split(',')
    .map((h) => h.trim())
    .filter(Boolean),
)
const VERIFY_URL =
  'https://challenges.cloudflare.com' +
  '/turnstile/v0/siteverify'

type Result = { ok: boolean; skipped: boolean }
const NO: Result = { ok: false, skipped: false }

export async function verifyTurnstile(
  token: string | null,
  ip: string,
  action: 'form' | 'signup',
): Promise<Result> {
  if (!SECRET) {
    console.warn('[turnstile] no secret: skipped')
    return { ok: false, skipped: true }
  }
  if (HOSTS.size === 0) return NO
  if (!token || token.length > 2048) return NO

  // One key for both attempts, so a retry is the
  // same verification, not a second redemption.
  const idempotency_key = crypto.randomUUID()

  for (let attempt = 1; attempt <= 2; attempt++) {
    const ctrl = new AbortController()
    const timer = setTimeout(() => ctrl.abort(), 4000)
    try {
      const body = new URLSearchParams({
        secret: SECRET,
        response: token,
        idempotency_key,
      })
      if (ip) body.set('remoteip', ip)
      const res = await fetch(VERIFY_URL, {
        method: 'POST',
        body,
        signal: ctrl.signal,
      })
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`)
      }
      const data = await res.json()
      if (data.success !== true) {
        // Codes go to the log, never the client.
        const codes = data['error-codes']
        console.warn('[turnstile] rejected', codes)
        return NO
      }
      const ok =
        data.action === action &&
        HOSTS.has(data.hostname)
      return { ok, skipped: false }
    } catch (err) {
      if (attempt === 2) {
        console.error('[turnstile] unreachable', err)
        return NO
      }
    } finally {
      clearTimeout(timer)
    }
  }
  return NO
}

A few decisions in there are worth reading twice.

It fails closed once configured. No token, an oversized token, a rejection, or two failed requests all return ok: false, and the hook rejects. A real visitor caught by a Cloudflare outage sees the error and the email fallback below, which is a better failure than a form that quietly accepts everything. A popular reCAPTCHA walkthrough for the same plugin gets this half right: it rejects a missing token and a failed success, but it compares the score only when the response includes one, never checks the action or hostname, and calls Google with no timeout.

A missing secret skips the check, loudly. That keeps local development working without keys, while the honeypot, timing and rate limit still run. The cost is that a production deploy missing the variable runs without Turnstile. If you'd rather that fail at boot, throw when NODE_ENV is production and the secret is unset. (Cloudflare's test secret keys always pass or always fail and don't return your real action and hostname, so our full version skips the binding check when one of them is configured.)

Retries reuse one idempotency_key. Tokens are single-use and valid for 300 seconds. If the first request times out after Cloudflare redeemed the token, a plain retry would come back as a duplicate and reject a real person. Cloudflare documents idempotency_key as "a UUID you generate to safely retry validation requests", so we generate one per verification and send it on both attempts. Only network errors and non-2xx responses are retried. A clear "no" from Cloudflare is final.

success isn't enough on its own. The token also has to carry the action this form rendered with and a hostname you serve. The action check stops a token minted on your newsletter widget from being spent on your contact form. The hostname check stops a token minted on someone else's site that embeds your public site key. An empty hostname list rejects everything, so a misconfiguration can't turn into an open door.

On the client, three details make this survivable for real visitors. Render the widget with the same action the server checks (up to 32 letters, numbers, _ or -). Get a fresh token after any failed submit, because the old one was spent on that attempt:

src/blocks/Form/Component.tsxTSX · excerpt

The spent token is cleared and the widget reset after any failed submit.

const res = await fetch('/api/form-submissions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'cf-turnstile-response': token ?? '',
    'x-hp': honeypotRef.current?.value ?? '',
    'x-elapsed': String(Date.now() - mountedAt),
  },
  body: JSON.stringify({
    form: formID,
    submissionData,
  }),
})
if (!res.ok) {
  const json = await res.json().catch(() => ({}))
  setError(json.errors?.[0]?.message ?? 'Try again.')
  // The token was redeemed by this attempt.
  setToken(null)
  window.turnstile?.reset(widgetId)
  return
}

Without that reset, a visitor who fixes a field the server rejected and submits again sends the same token, Cloudflare rejects it as a duplicate, and they get the spam message for a typo. (Ours removes the widget and renders a new one, which does the same job.)

And give people a way around the widget. Ad blockers and privacy extensions can stop challenges.cloudflare.com from loading, and some headless browsers never get a token. We keep the submit button paused until a token arrives, show a status line explaining why, switch it to an error with a "try again" button after 12 seconds without a token, and always print a line under the form: "Can't submit? Email hello@wabbit.com." The address is the link's text, so it can still be read and copied where mailto: does nothing.

Step 5: Rate-limit in the database, keyed on the right IP

An in-memory counter resets on every deploy and isn't shared between processes. If you already run MongoDB for Payload, a small raw collection with a TTL index does the job with nothing new to host. It isn't a Payload collection, so there's no schema and nothing in the admin.

src/utilities/formRateLimit.tsTypeScript

Fixed 10-minute window, hashed keys, Mongo deletes expired rows itself.

import { createHash } from 'node:crypto'
import type { MongooseAdapter } from
  '@payloadcms/db-mongodb'

const MAX = 5
const WINDOW_MS = 10 * 60_000
let indexReady: Promise<unknown> | null = null

export async function isRateLimited(
  db: MongooseAdapter,
  ip: string,
): Promise<boolean> {
  try {
    const col = db.connection.collection(
      '_form_rate_limits',
    )
    indexReady ??= col.createIndex(
      { expiresAt: 1 },
      { expireAfterSeconds: 0 },
    )
    await indexReady

    const bucket = Math.floor(Date.now() / WINDOW_MS)
    const ipHash = createHash('sha256')
      .update(ip)
      .digest('hex')
    const doc = await col.findOneAndUpdate(
      { _id: `${ipHash}:${bucket}` },
      {
        $inc: { count: 1 },
        $setOnInsert: {
          expiresAt: new Date(
            (bucket + 1) * WINDOW_MS,
          ),
        },
      },
      { upsert: true, returnDocument: 'after' },
    )
    return (doc?.count ?? 0) > MAX
  } catch (err) {
    indexReady = null
    console.warn('[rate-limit] store error', err)
    return false
  }
}

Each IP gets one document per 10-minute window, and $inc with upsert makes the count atomic across processes. expireAfterSeconds: 0 tells MongoDB to expire each document at the time stored in expiresAt, which we set to the end of its window. MongoDB's TTL monitor runs every 60 seconds, so rows can outlive their window by a minute or so. The count lives in the window key, so that delay doesn't change the result.

It fails open. If the store throws, the submission goes through, because a database hiccup shouldn't cost you a real lead while Turnstile is still guarding the door. Keys are hashed so raw IPs never sit in the collection. A bare SHA-256 of an IPv4 address can be reversed by brute force, so treat the hash as tidiness, not anonymity. If that matters for you, use an HMAC with a secret, and the rows expire within minutes either way.

The IP is where most rate limits go wrong:

src/utilities/formRateLimit.tsTypeScript · excerpt

Rightmost x-forwarded-for: the hop your own proxy added.

// Your proxy appends the address it saw to the
// right of x-forwarded-for. Anything to its left
// came from the client and can be forged.
export function clientIp(h: Headers): string {
  const hops = (h.get('x-forwarded-for') ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean)
  return (
    hops.at(-1) ||
    h.get('x-real-ip')?.trim() ||
    'unknown'
  )
}

Most snippets take the leftmost entry, the "original client". It's also the one an attacker writes: curl -H 'X-Forwarded-For: 1.2.3.4' would get a fresh bucket on every request. The rightmost entry is the one your own reverse proxy added from the socket it accepted. That holds with exactly one proxy you control in front of the app. With two (a CDN in front of your load balancer, say), count that many hops from the right, or read the client-IP header your CDN sets and strip it at the edge for traffic that didn't come through the CDN.

Step 6: Validate the fields the plugin doesn't

The plugin stores whatever submissionData arrives as long as each value isn't undefined. A direct POST can skip required fields, send 50,000 characters into a text field, pick a select option the form doesn't offer, or add fields the form never had. The website template's react-hook-form rules only run in the browser.

So load the form and check the submission against it. The checker is a pure function, which keeps it easy to test:

src/utilities/validateSubmissionData.tsTypeScript

Pure check of submissionData against the form's own field config.

export type FieldConfig = {
  blockType?: string
  name?: string
  label?: string
  required?: boolean | null
  options?: { value?: string }[] | null
}
type Entry = { field?: unknown; value?: unknown }

const MAX: Record<string, number> = {
  text: 500,
  textarea: 5000,
  email: 320,
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/

export function validateSubmission(
  fields: FieldConfig[],
  data: unknown,
): string[] {
  if (!Array.isArray(data) || data.length > 100) {
    return ['Invalid submission.']
  }
  const byName = new Map<string, FieldConfig>()
  for (const f of fields) {
    if (f.name && f.blockType !== 'message') {
      byName.set(f.name, f)
    }
  }

  const issues: string[] = []
  const seen = new Map<string, unknown>()
  for (const { field, value } of data as Entry[]) {
    const bad =
      typeof field !== 'string' || seen.has(field)
    if (bad) {
      issues.push('Invalid field.')
    } else if (!byName.has(field)) {
      issues.push(`${field} is not on this form.`)
    } else if (value && typeof value === 'object') {
      issues.push(`${field} has an invalid value.`)
    } else {
      seen.set(field, value)
    }
  }

  for (const [name, f] of byName) {
    const v = seen.get(name)
    const label = f.label || name
    const empty =
      v == null ||
      v === false ||
      (typeof v === 'string' && v.trim() === '')
    if (empty) {
      if (f.required) {
        issues.push(`${label} is required.`)
      }
      continue
    }
    const s = String(v)
    const type = f.blockType ?? ''
    const isChoice =
      type === 'select' || type === 'radio'
    if (s.length > (MAX[type] ?? 500)) {
      issues.push(`${label} is too long.`)
    } else if (
      type === 'email' &&
      !EMAIL.test(s.trim())
    ) {
      issues.push(`${label} must be a valid email.`)
    } else if (
      isChoice &&
      !f.options?.some((o) => o.value === v)
    ) {
      issues.push(`${label} has an invalid choice.`)
    }
  }
  return issues
}

Then finish the hook from step 3:

src/hooks/verifyFormSubmission.tsTypeScript · excerpt

Check 5 loads the form with overrideAccess and rejects with field labels.

const formId =
  typeof data?.form === 'object'
    ? data.form?.id
    : data?.form
const invalid = () =>
  new APIError('Invalid submission.', 400)
if (!formId) throw invalid()

const form = await req.payload
  .findByID({
    collection: 'forms',
    id: formId,
    depth: 0,
    overrideAccess: true,
    req,
  })
  .catch(() => null)
if (!form) throw invalid()

const issues = validateSubmission(
  (form.fields ?? []) as FieldConfig[],
  data?.submissionData,
)
if (issues.length) {
  const message = issues.slice(0, 5).join(' ')
  throw new APIError(message, 400)
}

Unknown field names are rejected, which catches junk and also any extra data your own front end appends. If your client adds hidden fields (we append first-touch attribution like sourceChannel), allow-list them by name with their own length caps. Our full version also checks checkboxes, numbers and dates by type, caps state and country values, and treats an unticked required checkbox as missing.

The messages use the labels editors gave the fields, and the hook only reaches this point after Turnstile passed, so a real person who somehow got past the browser rules sees which field to fix.

Step 7: Send your own confirmation email, in HTML and text

The plugin can send emails configured on each form, and that works for notifying your team. For the person who filled in the form, it has two problems. Messages are serialized to HTML with no text part, and replyTo falls back to the from address unless an editor fills it in. A confirmation that arrives HTML-only, from a no-reply address, with a subject like "New submission" doesn't tell the sender much.

We send the confirmation from our own afterChange hook instead:

src/hooks/sendSubmissionConfirmation.tsTypeScript

Creates only, skips spam, never blocks the submission.

import type { CollectionAfterChangeHook } from
  'payload'

export const sendSubmissionConfirmation:
  CollectionAfterChangeHook = async ({
    doc,
    operation,
    req,
  }) => {
  if (operation !== 'create') return doc
  // If you triage spam into a field, skip it here.
  if (doc.spamTriage?.verdict === 'spam') return doc

  const email = findEmail(doc.submissionData)
  if (!email) return doc

  try {
    const data = doc.submissionData
    const { subject, html, text } =
      buildConfirmation({
        firstName: findFirstName(data),
        request: await describeRequest(req, doc),
      })
    await req.payload.sendEmail({
      from: 'Your Team <hello@example.com>',
      to: email,
      replyTo: 'team@example.com',
      subject,
      html,
      text,
    })
  } catch (err) {
    const { payload } = req
    payload.logger.error({ err }, 'confirm failed')
    await notifyTeam(payload, 'Confirmation failed')
  }
  return doc
}

It sends on create only. It skips submissions your spam triage has already marked, because a "thanks for reaching out" to a scraper confirms the address is live. replyTo goes to a person who reads replies. A failure is logged and reported to the team, because a sender who never got their receipt is something you want to know about, and the submission itself still succeeds. payload.sendEmail passes html, text and replyTo through to the adapter; the official Resend adapter maps all three.

The template builds both bodies from one list of paragraphs, so the text version can't drift from the HTML one:

src/email/templates/confirmation.tsTypeScript

One paragraph list renders both bodies; the subject names the request.

type Seg = string | { link: string; href: string }

const esc = (s: string) =>
  s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')

const html1 = (s: Seg) =>
  typeof s === 'string'
    ? esc(s)
    : `<a href="${esc(s.href)}">${esc(s.link)}</a>`

const text1 = (s: Seg) =>
  typeof s === 'string' ? s : `${s.link} (${s.href})`

export function buildConfirmation(args: {
  firstName?: string
  request: string
}) {
  const hi = args.firstName
    ? `Hi ${args.firstName},`
    : 'Hi,'
  const paras: Seg[][] = [
    [hi],
    [`Your ${args.request} request is in.`],
    ['We’ll reply within one working day.'],
    ['To add anything, reply to this email.'],
  ]
  return {
    subject: `Your ${args.request} request is in`,
    text: paras
      .map((p) => p.map(text1).join(''))
      .join('\n\n'),
    html: paras
      .map((p) => `<p>${p.map(html1).join('')}</p>`)
      .join(''),
  }
}

The subject names the request ("Your fit call request is in" on our site), so the sender can find it later and knows at a glance it's not a newsletter. Everything that came from the visitor, the first name included, is escaped before it goes into the HTML body.

Watch the order of hooks. The plugin's own email hook runs first in afterChange, before yours, so a spam verdict stamped by one of your hooks can't stop an email configured on the form. Keep plugin-sent emails for internal notifications, or filter them in the plugin's beforeEmail option, which receives the prepared emails and returns the ones to send.

Step 8: Decide how long you keep submissions

Every submission is a name, an email address and whatever the person typed, and the plugin keeps them forever. Payload has no retention setting, so this one is yours to write. Our policy is 30 days for anything marked as spam and 24 months for everything else, enforced by a purge job:

src/lib/forms/submissionRetention.tsTypeScript

Counts first; deletes only with the flag on, in bounded batches.

import type { Payload, Where } from 'payload'

const DAY = 24 * 60 * 60 * 1000
const COL = 'form-submissions' as const

export async function purgeSubmissions(
  payload: Payload,
  batch = 100,
) {
  const apply =
    process.env.SUBMISSION_RETENTION_APPLY === 'true'
  const now = Date.now()
  const spam = new Date(now - 30 * DAY).toISOString()
  const d = new Date(now)
  d.setUTCMonth(d.getUTCMonth() - 24)
  const other = d.toISOString()

  const verdict = 'spamTriage.verdict'
  const buckets: Record<string, Where> = {
    spam: {
      and: [
        { createdAt: { less_than_equal: spam } },
        { [verdict]: { equals: 'spam' } },
      ],
    },
    other: {
      and: [
        { createdAt: { less_than_equal: other } },
        { [verdict]: { not_equals: 'spam' } },
      ],
    },
  }

  const report: Record<string, number> = {}
  let left = batch
  for (const [name, where] of Object.entries(
    buckets,
  )) {
    const { totalDocs } = await payload.count({
      collection: COL,
      where,
      overrideAccess: true,
    })
    report[`${name}Eligible`] = totalDocs
    if (!apply || left <= 0 || !totalDocs) continue

    const { docs } = await payload.find({
      collection: COL,
      where,
      sort: 'createdAt',
      limit: left,
      depth: 0,
      overrideAccess: true,
    })
    const ids = docs.map((d) => d.id)
    await payload.delete({
      collection: COL,
      where: { id: { in: ids } },
      overrideAccess: true,
    })
    report[`${name}Deleted`] = ids.length
    left -= ids.length
  }
  payload.logger.info(
    { apply, ...report },
    'retention',
  )
  return report
}

It starts in log-only mode. Until SUBMISSION_RETENTION_APPLY is true, it counts what it would delete and logs the numbers, so you can watch a few runs before anything disappears. Each run deletes at most one batch, oldest first, which keeps a first run against years of backlog from turning into one huge delete. The log carries counts only, never names or emails. On MongoDB, not_equals also matches documents with no verdict at all; on Postgres, check how your adapter treats a missing value, and add an exists: false branch if it leaves them out.

Something has to call it. A secret-protected route lets any scheduler do that (a Coolify scheduled task, a GitHub Actions cron, a hosted cron service):

src/app/api/cron/submission-retention/route.tsTypeScript

Bearer-protected, compared in constant time; the scheduler lives outside the app.

import {
  createHash,
  timingSafeEqual,
} from 'node:crypto'
import { getPayload } from 'payload'
import config from '@payload-config'
import { purgeSubmissions } from
  '@/lib/forms/submissionRetention'

export const dynamic = 'force-dynamic'

const sha = (s: string) =>
  createHash('sha256').update(s).digest()

export async function POST(req: Request) {
  const secret = process.env.CRON_SECRET
  const auth = req.headers.get('authorization')
  const ok =
    !!secret &&
    !!auth &&
    timingSafeEqual(
      sha(auth),
      sha(`Bearer ${secret}`),
    )
  if (!ok) {
    return new Response('Unauthorized', {
      status: 401,
    })
  }
  const payload = await getPayload({ config })
  const report = await purgeSubmissions(payload)
  return Response.json({ ok: true, ...report })
}

Run it daily. If you'd rather keep scheduling inside Payload, the same function works as a Payload job task, as long as something runs the job queue.

One thing the purge doesn't reach: copies. If a hook mirrors each submission into a CRM, a mailing list or a helpdesk, the person's details now live there too, and deleting the submission leaves those alone. Ours mirrors contacts into CRM collections, and those records are deliberately outside this job; they need their own policy. Write down where submitter data goes and how long each copy lives, then put one line by the form that says so.

Common mistakes

Verifying the CAPTCHA only in the browser. If the check lives in a client component, /api/form-submissions is still open to anyone with curl. The token has to be sent with the submission and verified in a server hook.

Verifying in a hook that runs twice. A Turnstile token can be redeemed once. If your verification runs in a hook that fires on create and again on an update (or you call siteverify from both a route wrapper and a hook), the second call fails. Check operation === 'create' and verify in one place.

Trusting the leftmost x-forwarded-for. It's whatever the client wrote. Rate limits keyed on it can be bypassed with one header per request.

Accepting success: true on its own. success: true without an action and hostname check accepts tokens minted for other forms and other sites. And a siteverify call with no timeout ties up the request for as long as the network does.

Required markers that read "star". A bare * inside a label is announced as a symbol. Hide it with aria-hidden and keep the "(required)" text.

HTML-only confirmations. Some spam filters score a message with no text part as more suspicious, and anyone who reads mail as plain text gets an empty or garbled message. Build both bodies from one source.

How Tome does it

Everything in the eight steps runs on wabbit.com, a site built on Tome, across every form on the site, including the multi-step intake forms that share the same /api/form-submissions endpoint and so pass through the same gate. The Form block, the field components and the Turnstile component live in the site, and the receipt and the confirmation email read one request mapping, so the page and the inbox always name the same thing. Tome's admin package adds a Form Submissions dashboard widget with per-form totals and a feed of recent submissions.

Tome Starter, the template new Tome sites begin from, ships the same gate: the beforeValidate hook with the honeypot, fill-time and rate-limit checks, Turnstile verification that fails closed whenever a secret is set, and field validation against the form's own fields. A new site gets all of it before it takes its first submission.

Checklist

  • Every field error has an id, and its input carries aria-invalid and aria-describedby.
  • The asterisk is aria-hidden, and a "* required" line comes before the first field.
  • Submit errors are role="alert", and the success receipt lands in a role="status" region that was already on the page.
  • The receipt and the email both say what was asked for and where the reply goes.
  • Spam checks run in a beforeValidate hook on create only: honeypot, fill time, rate limit, Turnstile, then field validation.
  • Siteverify fails closed, times out, retries once with one idempotency_key, and checks action and hostname.
  • The widget resets after a failed submit, and the page shows an email fallback.
  • The rate limit is shared, expires on its own, and keys on the IP your proxy added.
  • Submissions are purged on a schedule, and you know where the copies live.

These practices line up with checks on our Agent-Ready list: forms an agent can fill in, errors it can read, and a confirmation that tells it what was sent. See what an Agent-Ready website covers.

FAQ

How do I add Cloudflare Turnstile to the Payload form builder?

Render the Turnstile widget in your Form block and send its token with the submission, for example in a cf-turnstile-response header. Then add a beforeValidate hook to the form-submissions collection through the plugin's formSubmissionOverrides.hooks option, and call Cloudflare's siteverify endpoint from it with your secret key. Reject the create if siteverify fails or the token's action and hostname don't match.

Does the Payload form builder plugin have spam protection?

No. Create access on form-submissions is public by design and the plugin includes no CAPTCHA, honeypot or rate limit. Add them yourself in a beforeValidate hook on the collection, so they run for direct API posts as well as for your form.

Does the Payload form builder validate submissionData on the server?

Only barely: each value is checked for !== undefined. Required fields, lengths, email shape and select options aren't checked against the form. Load the form in a beforeValidate hook and compare submissionData with its fields before the submission is saved.

Should Turnstile verification go in beforeValidate or beforeChange?

beforeValidate. It runs first on create, so a failed check throws before validation or any write, and in Payload 3 it runs once per create, which matters because a Turnstile token can only be redeemed once. Return early when operation isn't 'create'.

How do I make Payload form errors accessible?

Give each error message an id and role="alert", and give its input aria-invalid and aria-describedby pointing at that id. Hide the asterisk from screen readers, explain it at the top of the form, and announce the success message through a role="status" region.

How do I send a plain-text confirmation email from a Payload form?

Write your own afterChange hook on form-submissions that runs on create and calls payload.sendEmail with both html and text, built from the same content. Set replyTo to an address someone reads, and name the request in the subject.