@humaan/payload-plugin-form-builder
v0.0.6
Published
Self-contained Payload form builder plugin with admin conveniences and frontend form components.
Readme
Payload Plugin Form Builder
Self-contained Payload 3 form builder plugin. It wraps @payloadcms/plugin-form-builder, adds form field authoring conveniences in the admin UI, stores submitted values encrypted by default, and exports frontend React components for rendering forms.
Install
pnpm add @humaan/payload-plugin-form-builder @payloadcms/plugin-form-builder @payloadcms/uiPayload, @payloadcms/plugin-form-builder, @payloadcms/ui, React, and React DOM are peer dependencies. Keep payload and all @payloadcms/* packages pinned to the same exact version in the consuming project. BotID and React Email are optional peers and are only needed when the matching plugin options are enabled.
Basic Usage
import { buildConfig } from 'payload'
import { payloadPluginFormBuilder } from '@humaan/payload-plugin-form-builder'
export default buildConfig({
plugins: [
payloadPluginFormBuilder({
redirectRelationships: ['pages'],
contentFormBlock: true,
}),
],
})This registers forms and form-submissions, enables the custom form field blocks, groups both collections under Forms in the admin sidebar, encrypts submitted field labels/values, and exposes the formatted submission data admin view.
Options
payloadPluginFormBuilder({
disabled?: boolean
adminGroup?: string | false
redirectRelationships?: CollectionSlug[]
contentFormBlock?: boolean | Partial<Block>
deliveryLogs?: boolean | {
emails?: boolean
retentionDays?: number | false
adminGroup?: string | false
}
hubspot?: {
portalId: string
timeoutMs?: number
token?: string
tokenEnv?: string
trackingContext?: boolean
uploads?: {
allowedMimeTypes?: string[]
maxFiles?: number
maxFileSize?: number
maxTotalSize?: number
}
}
webhooks?: boolean | {
enabled?: boolean
timeoutMs?: number
maxResponseBodyLength?: number
}
submissions?: {
encrypt?: boolean
botProtection?:
| false
| 'botid'
| { provider: 'botid' }
| {
provider: 'recaptcha'
secretKey?: string
secretKeyEnv?: string
tokenFieldName?: string
}
| {
provider: 'turnstile'
secretKey?: string
secretKeyEnv?: string
tokenFieldName?: string
}
useFormattedAdminView?: boolean
}
email?: {
template?: false | 'default'
}
formFieldHooks?: {
blocks?: {
all?: PayloadPluginFormBuilderFormFieldBlockHook
[slug]?: PayloadPluginFormBuilderFormFieldBlockHook
}
previews?: {
all?: PayloadPluginFormBuilderFormFieldPreviewHook
[slug]?: PayloadPluginFormBuilderFormFieldPreviewHook
}
}
formOverrides?: PluginConfig['formOverrides']
formSubmissionOverrides?: PluginConfig['formSubmissionOverrides']
})Defaults:
adminGroup: 'Forms'submissions.encrypt: truesubmissions.useFormattedAdminView: truesubmissions.botProtection: falseemail.template: falsedeliveryLogs: falsedeliveryLogs.emails: truedeliveryLogs.retentionDays: 90hubspot: not configuredhubspot.timeoutMs: 30000hubspot.tokenEnv: 'HUBSPOT_TOKEN'hubspot.trackingContext: truehubspot.uploads.allowedMimeTypes: []hubspot.uploads.maxFiles: 5hubspot.uploads.maxFileSize: 5242880(5 MiB)hubspot.uploads.maxTotalSize: 15728640(15 MiB)webhooks: falsewebhooks.timeoutMs: 10000webhooks.maxResponseBodyLength: 4000
Redirect Relationships
Set redirectRelationships to the collection slugs that can be selected as internal redirect targets when an editor sets a form's confirmation type to redirect.
payloadPluginFormBuilder({
redirectRelationships: ['pages'],
})This only configures the Payload admin field. The frontend renderer can redirect to custom URLs by default, but relationship redirects need a resolveRedirect callback so the consuming app can map the selected document to the correct public URL.
Per-Site Field and Preview Hooks
Use formFieldHooks when a site needs to adjust the generated form field blocks before Payload receives them. Global all hooks run first, slug-specific hooks run second, and preview hooks run after block hooks. formOverrides.fields still runs last for collection-level changes.
import type { FieldHook } from 'payload'
import { payloadPluginFormBuilder } from '@humaan/payload-plugin-form-builder'
const normalizeSiteCode: FieldHook = ({ value }) => String(value || '').toUpperCase()
payloadPluginFormBuilder({
formFieldHooks: {
blocks: {
text: ({ block }) => ({
...block,
fields: [
...block.fields,
{
name: 'siteCode',
type: 'text',
hooks: {
beforeChange: [normalizeSiteCode],
},
},
],
}),
},
previews: {
all: ({ preview, slug }) =>
typeof preview === 'string'
? preview
: {
alt: `${slug} field preview`,
url: preview?.url || `/admin/form-field-previews/${slug}.png`,
},
textarea: () => ({
alt: 'Long answer field preview',
url: '/admin/form-field-previews/long-answer.png',
}),
},
},
})To enable BotID submission protection, install botid, set submissions.botProtection: 'botid', and import the package instrumentation in your app:
import '@humaan/payload-plugin-form-builder/instrumentation-client'You can also use the object form:
payloadPluginFormBuilder({
submissions: {
botProtection: {
provider: 'botid',
},
},
})To enable Cloudflare Turnstile submission protection, set submissions.botProtection to the Turnstile provider and configure TURNSTILE_SECRET_KEY in the server environment:
payloadPluginFormBuilder({
submissions: {
botProtection: {
provider: 'turnstile',
},
},
})Then pass the matching public site key to the frontend form, or expose it as NEXT_PUBLIC_TURNSTILE_SITE_KEY:
import { Form } from '@humaan/payload-plugin-form-builder/client'
export function ContactForm({ form }) {
return (
<Form
botProtection={{
provider: 'turnstile',
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
}}
form={form}
/>
)
}Turnstile defaults to TURNSTILE_SECRET_KEY for server verification and __turnstileToken for the hidden token field. If you override tokenFieldName server-side, pass the same value to <Form />.
To enable Google reCAPTCHA submission protection, set submissions.botProtection to the reCAPTCHA provider and configure RECAPTCHA_SECRET_KEY in the server environment:
payloadPluginFormBuilder({
submissions: {
botProtection: {
provider: 'recaptcha',
},
},
})Then pass the matching public site key to the frontend form, or expose it as NEXT_PUBLIC_RECAPTCHA_SITE_KEY:
import { Form } from '@humaan/payload-plugin-form-builder/client'
export function ContactForm({ form }) {
return (
<Form
botProtection={{
provider: 'recaptcha',
siteKey: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
}}
form={form}
/>
)
}reCAPTCHA defaults to RECAPTCHA_SECRET_KEY for server verification and __recaptchaToken for the hidden token field. If you override tokenFieldName server-side, pass the same value to <Form />.
To wrap outgoing form-builder emails in the default email shell, install react-email and set email.template: 'default'.
Add-On Features
Delivery logs, webhooks, and HubSpot delivery are separate opt-in add-ons.
Set deliveryLogs: true to add a delivery-logs collection, a delivery log join on form submissions, and email delivery logging:
payloadPluginFormBuilder({
deliveryLogs: true,
})Delivery logs track attempted, dispatched, sent, and failed deliveries. Email logging is enabled by default when deliveryLogs is enabled:
payloadPluginFormBuilder({
deliveryLogs: {
emails: false,
},
})Set webhooks: true to add per-form webhooks and send configured form submission webhooks:
payloadPluginFormBuilder({
webhooks: true,
})Form webhook URLs must use https://. Custom webhook header values are encrypted at rest and can use tokens such as {{ form.title }}, {{ submission.id }}, {{ request.sourceUrl }}, and {{ fields.email }}.
Enable both add-ons to record webhook delivery attempts in delivery-logs:
payloadPluginFormBuilder({
deliveryLogs: true,
webhooks: true,
})HubSpot
Configure hubspot with an object to add per-form HubSpot delivery:
payloadPluginFormBuilder({
deliveryLogs: true,
hubspot: {
portalId: '12345678',
tokenEnv: 'HUBSPOT_TOKEN',
},
})hubspot has no true shorthand: portalId is required. token, tokenEnv, and trackingContext are optional. ipAddress is deliberately not a plugin option; when tracking context is enabled, the delivery hook derives it from the server-side request.
To find the IDs, open the HubSpot form's embed code. It contains the account ID as portalId and the form ID as formId:
hbspt.forms.create({
portalId: '12345678',
formId: '11111111-2222-3333-4444-555555555555',
})Use portalId in the plugin config and use formId as the per-form HubSpot Form GUID in Payload. Neither value is a secret; both are present in HubSpot's public browser embed. A private app token is a secret and must remain server-side.
The token is optional and never authenticates submission delivery. Configuring token directly, or exposing one through tokenEnv (which defaults to HUBSPOT_TOKEN), only enables config-time reads of the HubSpot form definition. Those checks verify that the form exists, flag mappings to properties that are not on the form, flag required properties that are not mapped, and detect the two fatal unsupported configurations below. A token also lets the HubSpot Form GUID field suggest the portal's forms by name. Saving remains available if validation fails or HubSpot is unavailable.
Without a token, submissions use the same unauthenticated delivery path, but the admin UI cannot perform those checks. It records that the configuration was not checked; you must verify the form and mappings in HubSpot yourself. The form GUID field stays a plain text input in that case.
In each Payload form's HubSpot tab:
- Enable HubSpot delivery and enter the HubSpot Form GUID, or pick one of the suggested forms.
- Explicitly map each Payload field to a property on that HubSpot form.
- Keep Tracking Page URL set to Relative unless the production site domain is connected to the HubSpot portal.
HubSpot delivery does not support either of these HubSpot form configurations. Both are fatal: every submission from this plugin will fail.
- HubSpot reCAPTCHA enabled
- HubSpot GDPR or consent options enabled
Use this plugin's own bot-protection options instead of enabling reCAPTCHA on the HubSpot form. Consent delivery is outside this integration's current scope.
Tracking context warning
trackingContext defaults to true. The plugin can send hutk, pageName, pageUrl, and the server-derived ipAddress. hutk is only available when the customer site installs HubSpot's tracking script, which sets the hubspotutk cookie. This plugin neither installs nor controls that script.
If the frontend receives a form through a Payload field select, include hubspot in the selection. The renderer reads form.hubspot.enabled to decide whether to capture HubSpot tracking context; omitting the group silently disables that capture.
Keep pageUrl relative by default. If Absolute sends a page URL whose domain is not connected to the HubSpot portal, HubSpot can silently destroy every submission: it returns 204, creates no record, and the delivery log still reads dispatched. The same setup can pass on localhost, so local testing does not prove that an absolute production URL is safe. Select Absolute only after connecting the production domain in HubSpot.
Delivery logs and Postgres migrations
HubSpot's terminal success status is dispatched, not sent. It means only that HubSpot accepted the request bytes; nothing further is known. The HubSpot channel never writes sent, and a 204 response does not prove that a contact or submission record was created.
The HubSpot channel widens the delivery-logs.channel enum with hubspot, widens delivery-logs.status with dispatched, and adds a nullable request textarea containing a redacted request summary. This package does not ship database migrations. Existing Postgres adopters must generate and apply an application migration for all three schema changes before deploying the HubSpot-enabled config.
File uploads
The HubSpot channel adds a pass-through file field. Files are validated in request memory, sent directly to HubSpot, and never persisted by this plugin. The defaults are 5 MiB per file, 5 files per field, and 15 MiB across one submission. Use hubspot.uploads to change those limits or to restrict the MIME types an editor may allow on a file field.
Frontend Rendering
import { Form } from '@humaan/payload-plugin-form-builder/client'
export function ContactForm({ form }) {
return <Form form={form} />
}The renderer is intentionally design-system neutral. It emits stable payload-form-builder__* class names and accepts renderRichText, renderConfirmation, and resolveRedirect callbacks for host-specific rendering.
Use resolveRedirect when forms can redirect to Payload relationship documents:
import { Form } from '@humaan/payload-plugin-form-builder/client'
export function ContactForm({ form }) {
return (
<Form
form={form}
resolveRedirect={(redirect) => {
if (
redirect &&
typeof redirect === 'object' &&
'type' in redirect &&
redirect.type === 'reference' &&
'reference' in redirect &&
redirect.reference &&
typeof redirect.reference === 'object' &&
'value' in redirect.reference
) {
const document = redirect.reference.value
if (
document &&
typeof document === 'object' &&
'slug' in document &&
typeof document.slug === 'string'
) {
return `/${document.slug}`
}
}
return undefined
}}
/>
)
}Additional exports:
ContentForm- field components such as
InputField,TextareaField,SelectField,CheckboxField,CheckboxGroupField, andRadioGroupField createFormSubmission
Development
pnpm generate:importmap
pnpm generate:types
pnpm lint
pnpm test:int
pnpm buildThe integration tests use mongodb-memory-server, so they need permission to bind local ports.
Releasing
Features land on main by pull request as usual. To cut a release:
Actions → Version → Run workflow, and pick a bump type (
patch,minor,major,prerelease, orcustomwith an explicit version).It bumps
package.json, regeneratesCHANGELOG.md, and opens achore(release): vX.Y.Zpull request.Nothing else to do.
humaan-botopens the pull request, its checks start automatically, and GitHub merges it with the repository's normal squash strategy once all lifecycle gates pass. Failed checks leave the pull request open and unmerged.CI runs on
main, then theReleaseworkflow publishes to npm, tagsvX.Y.Z, and creates the GitHub release.
The version bump cannot be pushed to main by CI — the branch ruleset requires status checks a
direct push can never satisfy — so it goes through the protected pull request path like any other
change. See ADR 0002 for the reasoning.
If a publish fails
Re-run Actions → Release → Run workflow with confirm set to publish. Nothing on main
changed, so the same unpublished version is picked up, and the tag is only pushed after a
successful publish — a failure never leaves an orphan tag behind.
Do not cut a new version to work around a failed publish. Version will refuse to bump while
the version on main is missing from npm, because that would skip a version number.
Ticking dry_run on Release reports the version and dist-tag it would use and publishes
nothing.
Bumping locally
Version runs the same script you can run yourself, if you would rather prepare the release
branch by hand:
pnpm release:bump patchIt rewrites package.json, regenerates CHANGELOG.md, and prints the branch and commit steps.
The pull request title must be exactly chore(release): vX.Y.Z — validate requires a
conventional commit, and Release keys off that prefix to decide whether a merge is a release.
Prerelease versions
A version with a prerelease identifier publishes under a matching dist-tag instead of latest:
alpha, beta, rc, and next map to themselves, anything else to next. The prerelease
bump type uses the beta identifier.
