@foundrykit/advanced-forms-plugin

中文文档
1.16.2发布于 2026/11/09MITTypeScriptES Module构建来源已验证近 30 天下载 2437

Multi-step enquiry form plugin for Payload CMS 3

原始使用文档
文档目录24 节
  1. Features
  2. Installation
  3. Quick start
  4. Plugin options
  5. Spam protection (reCAPTCHA v3)
  6. Collections
  7. Forms ( forms )
  8. Form Submissions ( form-submissions )
  9. Field types
  10. Travel date field (opt-in)
  11. Address field
  12. Submission actions
  13. REST API
  14. GET /api/form-data/:slug
  15. POST /api/form-submit/:formSlug
  16. Frontend usage
  17. Server component — fetch form data
  18. Client component — render the form
  19. Validation
  20. Hook — custom form UI
  21. Customising field blocks
  22. Architecture overview
  23. Development
  24. Releasing

@foundrykit/advanced-forms-plugin

A multi-step form plugin for Payload CMS 3. Adds a form builder to your admin panel with 11 built-in field types, configurable submission actions, and a ready-to-use React component for the frontend.

Features

  • Multi-step forms — organise fields into named steps with optional icons and a progress indicator
  • reCAPTCHA v3 — optional, invisible spam protection on every form
  • 12 field types — text, email, phone, textarea, checkbox, radio group, checkbox group, select, number, date, file upload, address (with postcode lookup)
  • Submission actions — send email, show a confirmation message, or redirect after submission (multiple actions per form, executed in order)
  • Two collections — forms and form-submissions added to your Payload config automatically
  • REST API — fetch form data and submit forms via Payload custom endpoints
  • Frontend component — <EnquiryForm> React client component with built-in step navigation and validation
  • Fully customisable — disable, replace, or add field block types; rename collection slugs

Installation

pnpm add @foundrykit/advanced-forms-plugin

Peer dependencies (install if not already present):

pnpm add payload react react-hook-form @radix-ui/react-popover

Quick start

// payload.config.ts
import { formPlugin } from '@foundrykit/advanced-forms-plugin'
import { buildConfig } from 'payload'

export default buildConfig({
  // Configure your email adapter here — the plugin uses payload.sendEmail
  // for the `sendEmail` submission action.
  // email: nodemailerAdapter({ ... }) or resendAdapter({ ... }), etc.
  plugins: [formPlugin({})],
  // ...rest of your config
})

The sendEmail submission action uses Payload's built-in payload.sendEmail, which delegates to the email adapter you configure on the Payload config. No plugin-level email handler is required.


Plugin options

formPlugin(options: FormPluginConfig)
OptionTypeDefaultDescription
disabledbooleanfalseRegister collections but skip adding endpoints
baseUrlstring''Base URL prepended when building form API URLs (e.g. https://example.com)
mediaCollectionstring'media'Slug of your media collection, used for step icon uploads
collections.formsstring'forms'Slug for the forms collection
collections.submissionsstring'form-submissions'Slug for the submissions collection
labels.formsstring'Form'Singular admin label for forms
labels.submissionsstring'Form Submission'Singular admin label for submissions
fieldsFieldsConfigall built-insControl which field block types are available in the form builder — see Customising field blocks

Spam protection (reCAPTCHA v3)

Pass a captcha config and every form's submit endpoint requires a verified reCAPTCHA v3 token. Leave it out — or leave either key unset — and nothing changes: no script is loaded, no token is required.

formPlugin({
  captcha: {
    siteKey: process.env.RECAPTCHA_SITE_KEY,
    secretKey: process.env.RECAPTCHA_SECRET_KEY,
    minScore: 0.5,        // optional, default 0.5
    action: 'form_submit' // optional, default 'form_submit'
  },
})

Nothing else to wire up. GET /api/form-data/:slug advertises the site key (public by design) to the browser, EnquiryForm mints a token per submission, and POST /api/form-submit/:formSlug verifies it before the form is even loaded. The secret key never leaves the server — an integration test asserts it is absent from the form response.

Score, not a challenge. v3 is invisible: it returns 0.0–1.0 and you choose the cut-off. Tune minScore from the reCAPTCHA console once you can see real traffic; too high silently rejects real people.

It fails open, on purpose. If Google is unreachable, or the secret is wrong, the submission is allowed and the reason is logged as an error. Blocking would throw away genuine enquiries for the length of an outage or a misconfiguration, which costs more than the spam it would stop. A missing or rejected token is still a hard 403 — only infrastructure failure is forgiven. Watch the logs for [forms] captcha could not be verified.

Register the site key against every domain that serves forms, including localhost for development, or every local submit will fail verification.


Collections

Forms (forms)

Stores form definitions. Publicly readable; create/update/delete require authentication.

Each form document has:

  • title — display name
  • slug — unique identifier used in the API (e.g. contact-form)
  • steps — one or more steps, each with a title, optional icon, and a list of field blocks
  • additionalContent — optional rich text positioned above or below the form
  • submissionActions — one or more actions executed on successful submission

Form Submissions (form-submissions)

One document per submission. Stores:

  • title — who sent it: the answer named name, full-name or first-name + last-name, else the email, else the date. Used as the page title.
  • form — relationship to the parent form
  • submittedAt — ISO timestamp
  • data — key/value pairs of submitted field values
  • metadata — userAgent, ip, referrer captured from the request

In the admin, a submission's answers are shown under the form's own questions, grouped by step, with option values shown as their labels and phone, counter and address answers spelled out. Edit on the Answers card turns text answers into inputs and choice answers into dropdowns, so staff can fix a mistyped email before resending; the change is kept when they save. Phone, counter and address answers can't be edited there. The form, the date, and a Details card (the page it came from, IP, browser and the submission context) are read-only.

Sites can show their own submission fields in the same card style with SubmissionPanel from @foundrykit/advanced-forms-plugin/rsc, as a field's admin.components.Field:

import { SubmissionPanel } from '@foundrykit/advanced-forms-plugin/rsc'

export const NotesField = ({ data }) => (
  <SubmissionPanel
    heading="Notes"
    id="notes"
    groups={[{ items: [{ label: 'Follow-up', value: data?.followUp ?? '' }] }]}
  />
)

Upgrading to the submission view on Postgres: the title column is new, so add it with a migration (ALTER TABLE "form_submissions" ADD COLUMN "title" varchar;) and regenerate the import map. Older submissions get their title on read until they are next saved.


Field types

Block slugDescription
textSingle-line text input (supports half width)
emailEmail address input
phonePhone number input
textareaMulti-line text (configurable rows)
checkboxSingle boolean checkbox
radioGroupSingle choice from a list (row or grid layout)
checkboxGroupMultiple choices from a list (row or grid layout)
selectDropdown select
numberNumeric input with optional min, max, step
dateDate picker with optional min/max
fileFile upload to a Payload collection (configurable accept, maxSizeMB)
addressAddress with UK postcode lookup (configurable defaultCountry, showLine2, lookupLabel)

All field blocks share these base properties: name, label, required, tooltip.

Travel date field (opt-in)

"When would you like to travel?": a calendar panel where the visitor picks a month (a year of month buttons) or an exact date (a day calendar, via react-day-picker). It's our own panel rather than native selects and date inputs, so sites can brand it: style the enquiry-field__calendar* classes. It isn't in the default set, so adding it to the plugin doesn't change any existing site's schema. Register it, then add a migration for its table on Postgres:

import { formPlugin, TravelDateBlock } from '@foundrykit/advanced-forms-plugin'

formPlugin({ fields: { travelDate: { block: TravelDateBlock } } })

The answer is stored as 2027-08 or 2027-08-14 and shows on the submission page as "August 2027" or "14 Aug 2027". parseTravelDate(value) gives the first and last day it covers (from/to, the same day for an exact date) and that label, for mapping into a CRM.

To ask "Are you flexible?" and only require a date when they aren't, add a Yes / No field and give the travel date a visibility rule: Require this field when rules match, flexible equals no.

Address field

Stores a composite value — { line1, line2, city, county, postcode, country } — serialised to JSON in the submission, the same way phone stores { country, number, e164 }.

The lookup is backed by postcodes.io: free, no API key, nobody billed per call, so the field can go on any public form without watching a quota. It resolves a postcode to its town, county and country only — it does not return a premises list, so the street line is always typed. Manual entry is never blocked, which keeps the field usable for non-UK addresses and when the lookup is unreachable.

A required address needs a street line and a postcode: a postcode alone is not deliverable, and the lookup can never supply the street. Reuse the lookup outside a form with the exported helper:

import { lookupPostcode } from '@foundrykit/advanced-forms-plugin/client'

const result = await lookupPostcode('SW1A 1AA')
// { status: 'ok', address: { postcode, city, county, country } }
// | { status: 'notFound' } | { status: 'error' }

Submission actions

Configure one or more actions per form. They execute in the order they appear.

BlockDescription
sendEmailSends an email via payload.sendEmail (uses your Payload email adapter). Subject supports {{fieldName}} interpolation.
confirmationMessageReturns a Lexical rich-text message in the API response for the frontend to display.
redirectReturns a redirectUrl in the API response. The frontend component handles the navigation.

REST API

Both endpoints are registered at the Payload API root (/api by default).

GET /api/form-data/:slug

Fetches a form document by its slug.

Response: the full FormDocument JSON.

POST /api/form-submit/:formSlug

Submits field data for a form.

Request body:

{
  "data": { "name": "Alice", "email": "[email protected]" },
  "metadata": { "referrer": "https://example.com/about" }
}

Success response (200):

{
  "success": true,
  "actions": {
    "confirmationMessage": { /* Lexical rich-text node */ },
    "redirectUrl": "/thank-you"
  }
}

Validation error response (422):

{
  "success": false,
  "errors": [{ "field": "email", "message": "Email is required" }]
}

Frontend usage

Server component — fetch form data

Import from @foundrykit/advanced-forms-plugin/rsc:

import { fetchForm } from '@foundrykit/advanced-forms-plugin/rsc'

export default async function ContactPage() {
  const form = await fetchForm({ slug: 'contact-form' })
  return <ContactForm form={form} />
}

fetchForm options:

OptionTypeDefaultDescription
slugstringrequiredThe form slug to fetch
baseUrlstring''Override the API base URL
formsSlugstring'forms'Override the forms collection slug used in the URL path

Client component — render the form

Import from @foundrykit/advanced-forms-plugin/client:

'use client'

import { EnquiryForm } from '@foundrykit/advanced-forms-plugin/client'
import type { FormDocument } from '@foundrykit/advanced-forms-plugin'

export function ContactForm({ form }: { form: FormDocument }) {
  return (
    <EnquiryForm
      form={form}
      onSuccess={(result) => console.log('submitted', result)}
      onError={(err) => console.error(err)}
    />
  )
}

EnquiryForm props:

PropTypeDefaultDescription
formFormDocumentrequiredForm data returned by fetchForm
apiBasestring''Base URL for the submit endpoint
classNamestring—CSS class applied to the root element
onSuccess(result: SubmitResult) => void—Called after successful submission
onError(error: SubmitError) => void—Called when the server returns validation errors
additionalContentReactNode—Extra content rendered above or below the form (respects the additionalContent.position setting)
resolverResolver<Record<string, unknown>>—Optional react-hook-form resolver (e.g. zodResolver(schema)). Merged with the per-field admin rules.

Validation

Every field block exposes an admin Validation group: requiredMessage, minLength / maxLength / pattern / patternMessage (for text-shaped fields), and min / max / minMessage / maxMessage (for numeric fields and the multi-counter total). These rules are wired into RHF via the buildFieldRules helper and are also exported from /client for use in custom field components.

For richer cross-field logic, pass a form-level resolver. The plugin does not bundle zod or @hookform/resolvers — install them in your app:

pnpm add zod @hookform/resolvers
'use client'

import { EnquiryForm } from '@foundrykit/advanced-forms-plugin/client'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z
  .object({
    travelKnown: z.enum(['yes', 'no']).optional(),
    travelPlans: z.string().optional(),
  })
  .passthrough()
  .superRefine((data, ctx) => {
    if (data.travelKnown === 'no' && !data.travelPlans) {
      ctx.addIssue({
        path: ['travelPlans'],
        code: 'custom',
        message: 'Tell us a bit about what you’re looking for',
      })
    }
  })

export function ContactForm({ form }) {
  return <EnquiryForm form={form} resolver={zodResolver(schema)} />
}

Per-field admin rules and the resolver run together — either source can flag a field invalid, and step-by-step validation (goNext) respects both.

Hook — custom form UI

If you need full control over the form UI, use useEnquiryForm directly:

'use client'

import { useEnquiryForm } from '@foundrykit/advanced-forms-plugin/client'

export function MyCustomForm({ form }) {
  const { currentStep, totalSteps, stepData, form: rhf, goNext, goBack, submit, isSubmitting } =
    useEnquiryForm({ form, apiBase: '' })

  // render whatever you like
}

Customising field blocks

The fields option in the plugin config controls which block types appear in the form builder:

formPlugin({
  fields: {
    // disable a built-in type
    file: false,

    // replace a built-in type with your own block
    text: { block: myCustomTextBlock },

    // add a new type
    budgetRange: { block: BudgetRangeBlock },
  },
})

Built-in slugs: text, email, phone, textarea, checkbox, radioGroup, checkboxGroup, select, number, date, file, address.


Architecture overview

src/
├── index.ts                  # Plugin entry — merges collections and endpoints into Payload config
├── types.ts                  # All shared TypeScript types (field blocks, FormDocument, plugin config)
├── collections/
│   ├── Forms.ts              # createFormsCollection factory
│   └── Submissions.ts        # createSubmissionsCollection factory
├── blocks/
│   ├── fields/               # One file per built-in field block type
│   └── submissionActions/    # SendEmail, ConfirmationMessage, Redirect blocks
├── endpoints/
│   ├── fetchFormHandler.ts   # GET /api/form-data/:slug
│   └── submitFormHandler.ts  # POST /api/form-submit/:formSlug — validates, stores, fires actions
├── components/
│   └── EnquiryForm/          # React client component and useEnquiryForm hook
├── utilities/
│   ├── fetchEnquiryForm.ts   # fetchForm — RSC utility wrapping the fetch endpoint
│   ├── buildFormURL.ts       # URL builder for the form-data endpoint
│   └── sanitizeSubmission.ts # Sanitises raw form data before storage
└── exports/
    ├── client.ts             # @foundrykit/advanced-forms-plugin/client exports
    └── rsc.ts                # @foundrykit/advanced-forms-plugin/rsc exports

The plugin follows the standard Payload plugin pattern: formPlugin(options) returns a function that receives and returns the Payload Config object. Collections and endpoints are added using spread syntax to avoid overwriting existing config.


Development

pnpm dev        # Start the dev app at http://localhost:3000
pnpm test       # Run unit + integration + e2e tests
pnpm build      # Compile to dist/

Copy dev/.env.example to dev/.env and set DATABASE_URL and PAYLOAD_SECRET before running dev.

Releasing

Run pnpm release on prod. It bumps the version, updates the changelog and pushes a v* tag. The tag triggers .github/workflows/publish.yml, which publishes to npm through trusted publishing, so no npm token is stored locally or in GitHub.

版本与发布记录

@foundrykit/advanced-forms-plugin 共发布 33 个版本,首次发布于 2026/05/28;过去 12 个月发布了 33 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.16.2latest2026/11/096 天前
1.16.12026/11/096 天前
1.16.02026/11/0610 天前
1.15.12026/11/0610 天前
1.15.02026/11/0610 天前
1.14.12026/10/3116 天前

关于 @foundrykit/advanced-forms-plugin 的常见问题

@foundrykit/advanced-forms-plugin 是什么?

Multi-step enquiry form plugin for Payload CMS 3

如何安装 @foundrykit/advanced-forms-plugin?

在项目目录运行 npm install @foundrykit/advanced-forms-plugin,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^19)、payload(^3.84.1)、react-hook-form(^7)、@radix-ui/react-popover(^1.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@foundrykit/advanced-forms-plugin 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@foundrykit/advanced-forms-plugin 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@foundrykit/advanced-forms-plugin 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js ^18.20.2 || >=20.9.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@foundrykit/advanced-forms-plugin 可以商用吗?

@foundrykit/advanced-forms-plugin 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@foundrykit/advanced-forms-plugin 的下载量有多少?

根据 npm 官方下载统计,@foundrykit/advanced-forms-plugin 在 2026/10/16 – 2026/11/14 这 30 天内被下载 2,437 次,日均约 81 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@foundrykit/advanced-forms-plugin 还在维护吗?

最新版本 1.16.2 发布于 2026/11/09(6 天前);至今共发布 33 个版本,其中过去 12 个月发布了 33 个;首次发布于 2026/05/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。