Form Builder
Create and manage dynamic forms with a visual drag-and-drop builder, submission tracking, email notifications, spam protection, webhooks, and data export.
Alpha (
0.x) — pin your versions.The plugin SDK is alpha: its
@publicsurface becomes semver-protected at 1.0 (see API stability). Until then, pin exact versions of the plugin packages and read the release notes before upgrading.
The Form Builder plugin adds a complete form management system to Nextly. Build forms visually in the admin panel or define them in code, collect submissions, send email notifications, and protect against spam -- all without writing custom endpoints.
Installation
npm install @nextlyhq/plugin-form-builderThe plugin declares these peer dependencies:
| Peer | Range |
|---|---|
nextly | the matching lockstep version, pinned exactly |
@nextlyhq/admin | the matching lockstep version, pinned exactly |
@nextlyhq/plugin-sdk | the matching lockstep version, pinned exactly |
@nextlyhq/ui | a caret range on the matching lockstep version |
@tanstack/react-query | >= 5 |
lucide-react | >= 0.400.0 |
react | ^18 or ^19 |
next | ^14, ^15 or ^16 |
While Nextly is in alpha every publishable package ships on one train, so three of the four
Nextly peers are pinned to the exact version the plugin was built against, and @nextlyhq/ui
publishes as a caret range on it. Either way, install the plugin and your core at the same
version rather than reaching for a range of your own. @tanstack/react-query and
lucide-react are the two most likely to be missing from a project that has not needed
them yet, and their absence shows up as a broken admin screen rather than an install
error.
Basic Setup
// nextly.config.ts
import { defineConfig } from 'nextly';
import { formBuilder } from '@nextlyhq/plugin-form-builder';
const fb = formBuilder();
export default defineConfig({
plugins: [fb.plugin],
collections: [Posts, Users, Media],
});This creates two collections automatically:
| Collection | Default Slug | Purpose |
|---|---|---|
| Forms | forms | Stores form definitions (fields, settings, notifications) |
| Submissions | form-submissions | Stores submitted data with status tracking |
Both collections appear in the admin sidebar under the Forms group.
Configuration Options
Pass an options object to formBuilder() to customize behavior.
// nextly.config.ts
const fb = formBuilder({
// Customize collection slugs and labels
formOverrides: {
slug: 'contact-forms',
labels: { singular: 'Contact Form', plural: 'Contact Forms' },
},
formSubmissionOverrides: {
slug: 'responses',
labels: { singular: 'Response', plural: 'Responses' },
},
// Enable/disable specific field types
fields: {
file: false, // Disable file uploads
hidden: false, // Disable hidden fields
},
// Email notifications
notifications: {
defaultFrom: 'noreply@example.com',
defaultToEmail: 'submissions@example.com',
enabled: true,
},
// Spam protection
spamProtection: {
honeypot: true,
rateLimit: {
maxSubmissions: 10,
windowMs: 60_000,
},
},
// File upload limits
uploads: {
maxFileSize: 10_485_760, // 10 MB
allowedMimeTypes: ['image/*', 'application/pdf', 'text/*'],
uploadCollection: 'media',
},
// Feature flags
features: {
builder: true,
conditionalLogic: true,
fileUploads: true,
},
// Collections that can serve as redirect targets after submission
redirectRelationships: ['pages'],
});Collection Overrides
You can add custom fields to the Forms or Submissions collections, or restrict access control.
Array style -- appends fields to the defaults:
formOverrides: {
fields: [
text({ name: 'internalNotes', label: 'Internal Notes' }),
],
access: {
read: () => true,
create: ({ permissions }) => permissions.includes('forms:create'),
},
}By default a visitor can read a form and create a submission. Everything else needs the
matching permission on the Roles screen: read-form-submissions to read submissions,
update-forms to edit a form, delete-form-submissions to delete one. An override narrows
from there; check the permission in it rather than a role's name.
Function style -- full control over the field list:
formSubmissionOverrides: {
fields: ({ defaultFields }) => {
const formIndex = defaultFields.findIndex(f => f.name === 'form');
return [
...defaultFields.slice(0, formIndex + 1),
{ name: 'source', type: 'text' },
...defaultFields.slice(formIndex + 1),
];
},
}Field Types
All 13 field types are enabled by default. Set any to false in the fields option to remove it from the builder.
| Type | Description | Key Options |
|---|---|---|
text | Single-line text input | minLength, maxLength, pattern |
email | Email address with format validation | pattern |
number | Numeric input | min, max, step |
phone | Phone number input | pattern |
url | URL with format validation | pattern |
textarea | Multi-line text input | rows, minLength, maxLength |
select | Dropdown menu | options, allowMultiple |
checkbox | Single boolean toggle | defaultValue |
radio | Radio button group | options |
file | File upload | accept, multiple, maxFileSize |
date | Date picker | min, max |
time | Time picker | defaultValue (HH:mm format) |
hidden | Hidden value (not shown to users) | defaultValue |
Every field supports these common properties: name, label, placeholder, helpText, defaultValue, required, validation, conditionalLogic, and admin.width (25% to 100%).
Creating Forms
Visual form builder (admin UI)
Navigate to Forms in the admin sidebar and click Create New. The form builder provides:
- Field Library -- click or drag field types to add them to the form
- Form Canvas -- reorder fields with drag-and-drop, click to edit
- Field Editor -- configure each field's label, validation, placeholder, help text, and conditional logic
- Settings Tab -- submit button text, confirmation type (message or redirect), multiple submission settings
- Notifications Tab -- configure email integrations per form
- Preview -- see how the form will look to end users
Forms have three statuses:
- Draft -- not accepting submissions (use for building)
- Published -- live and accepting submissions
- Closed -- shows a configurable closed message
Code-First Definitions
Use the field helper functions to define forms programmatically. These helpers set the type property automatically.
import {
text,
email,
textarea,
select,
checkbox,
option,
} from '@nextlyhq/plugin-form-builder';
const contactFields = [
text({ name: 'firstName', label: 'First Name', required: true }),
text({ name: 'lastName', label: 'Last Name', required: true }),
email({ name: 'email', label: 'Email Address', required: true }),
select({
name: 'subject',
label: 'Subject',
required: true,
options: [
option('General Inquiry'),
option('Support Request'),
option('Feedback'),
],
}),
textarea({
name: 'message',
label: 'Message',
required: true,
rows: 5,
validation: { minLength: 10, maxLength: 2000 },
}),
checkbox({ name: 'subscribe', label: 'Subscribe to our newsletter' }),
];The option() helper converts labels to slug values automatically ('General Inquiry' becomes 'general_inquiry'). Pass a second argument for an explicit value: option('United States', 'us').
Additional field helpers: number(), phone(), url(), radio(), file(), date(), time(), hidden().
Utility Functions
Create a complete form config with defaults applied in one call:
import { createFormConfig } from '@nextlyhq/plugin-form-builder';
const contactForm = createFormConfig('contact', contactFields, {
settings: {
submitButtonText: 'Send Message',
confirmationType: 'message',
successMessage: 'Thanks! We will get back to you within 24 hours.',
},
});Validate a form config before saving:
import { validateFormConfig, assertValidFormConfig } from '@nextlyhq/plugin-form-builder';
const result = validateFormConfig(contactForm);
if (!result.valid) {
console.error(result.errors);
}
// Or throw on invalid config
assertValidFormConfig(contactForm);Form Submissions
The handlers below take a { pluginContext, pluginConfig } context. pluginContext is the ctx your code already receives from the plugin system -- for example a plugin route handler's (req, ctx) => ..., or your own plugin's init(ctx). pluginConfig is the object formBuilder() returned: keep the result and use fb.config. Note that getFormBuilderConfig(ctx) is not an alternative from another plugin's context — the resolved config is written onto the form-builder's own context during its init, and initializePlugins gives every plugin a separate one, so that call returns undefined anywhere else.
Handling Submissions
Use the submitForm handler to process submissions server-side. It runs the full pipeline: fetch form, validate data, check spam, store submission, and determine redirect.
import { submitForm } from '@nextlyhq/plugin-form-builder';
import type { PluginContext } from '@nextlyhq/plugin-sdk';
import type { ResolvedFormBuilderConfig } from '@nextlyhq/plugin-form-builder';
declare const request: Request;
declare const pluginContext: PluginContext;
declare const pluginConfig: ResolvedFormBuilderConfig;
const result = await submitForm(
{
formSlug: 'contact',
data: { firstName: 'Jane', email: 'jane@example.com', message: 'Hello!' },
// The language the visitor submitted in. A picked redirect page is read in
// it, so a page whose slug is `thanks` in English and `merci` in French
// sends a French visitor to `/merci`. Pass whatever you resolved for the
// page the form was on; the built-in route reads it from `?locale=`.
locale: 'fr',
metadata: {
// No ipAddress here on purpose: see "Where the IP has to come from"
// below. Reading `x-forwarded-for` straight off the request is the one
// thing not to do.
userAgent: request.headers.get('user-agent') || undefined,
},
},
{ pluginContext, pluginConfig }
);
if (result.success) {
if (result.redirect) {
// Redirect the user
}
// result.submission contains the stored document
} else {
// result.error and result.validationErrors describe what went wrong
}Where the IP has to come from
submitForm keys two protections on metadata.ipAddress: the per-IP rate limit,
and the one-submission-per-visitor check when allowMultipleSubmissions is off. It
also stores the value on the submission.
That makes the header a control input, not a log field. x-forwarded-for is
client-supplied on a direct request and on any proxy that forwards it verbatim, so
a bot that varies it on every request gets a fresh rate-limit bucket each time and
walks past a limit that looks configured. Nextly closed exactly this in its own
auth and rate-limit paths, which is why getTrustedClientIp exists:
// nextly.config.ts, or anywhere the application itself runs
import { getTrustedClientIp, parseTrustedProxyIpsEnv } from "nextly";
declare const request: Request;
declare const trustProxy: boolean;
const ipAddress =
getTrustedClientIp(request, {
trustProxy,
trustedProxyIps: parseTrustedProxyIpsEnv(process.env.TRUSTED_PROXY_IPS),
}) ?? undefined;It walks the forwarded chain from the right, skipping hops in TRUSTED_PROXY_IPS,
and returns the first one you did not vouch for. It returns null when it cannot
identify a client, and passing undefined on is the correct thing to do with that:
the limiter skips a submission it cannot attribute rather than filing every unknown
caller in one bucket.
Two consequences worth stating plainly. security.trustProxy defaults to false,
so until you set it and list your proxies the resolver returns null and the rate
limit does not run. And getTrustedClientIp ships from nextly, not from
@nextlyhq/plugin-sdk, so a distributed plugin cannot reach it through the surface
it is supposed to depend on. An application that hosts its own route, as below, can.
Validation
The plugin generates Zod schemas from form field definitions at runtime. Every submission is validated against the schema before storage, and free-text fields are sanitized by stripping HTML tags to prevent injection.
That happens on a beforeChange hook on the submissions collection, the last collection-level mutating phase before the insert, so it applies to every way a submission can be written: your own route calling submitForm, the built-in POST /admin/api/forms/:slug/submit, nextly.forms.submit(), an admin editing a stored submission, and a record created from the admin. A submission is also projected onto the fields the form declares, so a key the form does not define is dropped rather than stored. Field-level beforeChange hooks run after this one and are handed the whole record, so one you register on this collection yourself is trusted the way core trusts a field hook on any other collection.
Values are sanitized before they are validated, so every rule judges the value that will actually be stored rather than the one that arrived.
A submission flagged as spam is stored without being validated, deliberately, so a false positive stays reviewable rather than being thrown away. That exception ends when the row leaves spam: marking one "Not spam" checks the payload it is carrying against the form, so a row that never satisfied the form has to be corrected in the same edit before it can become an ordinary counted submission.
Spam protection runs on the same hook, and is decided BEFORE the payload is validated. A bot that trips the honeypot while also omitting a required field is stored flagged rather than answered with a validation error, because an error tells it which of the two it got wrong.
The honeypot and the rate limit are facts about an HTTP REQUEST rather than about a row, so they stand down when no request produced the write: a trusted server importing a thousand submissions does not rate-limit its own import, and a honeypot field means nothing without a browser to have skipped it. What separates the two is whether a request reached the hook, not which function was called.
You do not need a route of your own to get any of it. POST /admin/api/forms/:slug/submit is contributed by this plugin and calls submitForm, so a browser posting to it meets the honeypot and the rate limit exactly as one posting to a route you host yourself would. Host your own when you want a different response shape or extra work around the submission, not to obtain spam protection.
The plugin's routes sit directly under wherever your app mounts createDynamicHandlers, which a scaffolded project does at src/app/admin/api/[[...params]]/route.ts: hence /admin/api. A bare /api/forms/... is a 404 there (see Routes).
On a localized site, post to /admin/api/forms/:slug/submit?locale=fr from a French page. The locale decides which translation the redirect target is read in, so the visitor lands on that page's French URL; a submission that names none is answered in the default language. A code the site does not have reads as the default rather than refusing the submission — the visitor filled the form in, and a wrong query string is not a reason to lose it. The target is read as the visitor, so a translation that is still a draft is not where they are sent: the published default answers until the translation is published.
You can validate data without creating a submission:
import { validateSubmission } from '@nextlyhq/plugin-form-builder';
import type { PluginContext } from '@nextlyhq/plugin-sdk';
import type { ResolvedFormBuilderConfig } from '@nextlyhq/plugin-form-builder';
declare const pluginContext: PluginContext;
declare const pluginConfig: ResolvedFormBuilderConfig;
const { valid, errors } = await validateSubmission(
'contact',
{ firstName: 'Jane', email: 'not-an-email' },
{ pluginContext, pluginConfig }
);Submission Status
Each submission tracks its review state:
| Status | Description |
|---|---|
new | Unread submission |
read | Reviewed by an admin |
archived | Archived (hidden from default views) |
Admins can also add internal notes to any submission -- these are not visible to the original submitter.
Statistics
import { getFormSubmissionStats } from '@nextlyhq/plugin-form-builder';
import type { PluginContext } from '@nextlyhq/plugin-sdk';
import type { ResolvedFormBuilderConfig } from '@nextlyhq/plugin-form-builder';
declare const pluginContext: PluginContext;
declare const pluginConfig: ResolvedFormBuilderConfig;
const stats = await getFormSubmissionStats('contact', { pluginContext, pluginConfig });
// { total: 42, new: 5, read: 30, archived: 7 }Email Notifications
Notifications are configured per form in the admin UI under the Notifications tab. Each notification integration specifies:
| Setting | Description |
|---|---|
templateSlug | Email template to use (from your email configuration) |
recipientType | "static" (fixed email) or "field" (value from a form field) |
to | Email address, or {{fieldName}} reference when using field recipient |
providerId | Email provider to use (optional, falls back to system default) |
cc / bcc | Additional recipients |
Template variables available in your email templates: all submitted field values, plus formName and submissionId.
beforeEmail Hook
Modify or filter outgoing emails before they are sent:
const fb = formBuilder({
beforeEmail: async ({ emails, form, submission }) => {
return emails.map(email => ({
...email,
bcc: ['archive@example.com'],
}));
},
});Spam Protection
Three layers of protection are available, all enabled by default (except reCAPTCHA).
Honeypot Fields
Hidden form fields that bots fill in automatically. When a honeypot field contains a value, the submission is stored flagged as spam and the bot receives an ordinary success response, so the detection is not revealed.
Standard honeypot field names checked: __honeypot, _honeypot, honeypot, __hp, _hp, website, url_field, fax_number.
Rate Limiting
Limits submissions per IP address and form within a time window. Defaults to 10 submissions per 60 seconds per IP.
spamProtection: {
rateLimit: {
maxSubmissions: 5,
windowMs: 120_000, // 2 minutes
},
}The rate limit uses the deployment's shared rate-limit store, rateLimit.store in defineConfig, which falls back to memory. With several instances and no shared store, each instance counts on its own.
reCAPTCHA v3
Configuration is available for Google reCAPTCHA v3. Set the site key and secret key globally or per form.
Not enforced yet. The settings below are accepted and stored, and nothing verifies a token against Google: submissions are accepted whether the token is valid, invalid or absent. Turning it on protects nothing today. The honeypot and the rate limit above are enforced.
spamProtection: {
recaptcha: {
enabled: true,
siteKey: 'your-site-key',
secretKey: 'your-secret-key',
scoreThreshold: 0.5,
},
}Conditional Logic
Fields can be shown or hidden based on the values of other fields. Configure conditional logic on any field through the admin UI or in code.
import { text, select, option } from '@nextlyhq/plugin-form-builder';
const fields = [
select({
name: 'contactMethod',
label: 'Preferred Contact Method',
options: [option('Email'), option('Phone')],
}),
text({
name: 'phoneNumber',
label: 'Phone Number',
conditionalLogic: {
enabled: true,
action: 'show',
operator: 'AND',
conditions: [
{ field: 'contactMethod', comparison: 'equals', value: 'phone' },
],
},
}),
];Supported comparison operators: equals, notEquals, contains, isEmpty, isNotEmpty, greaterThan, lessThan.
Multiple conditions can be combined with AND (all must match) or OR (any must match). The action property determines whether matching conditions show or hide the field.
Webhooks
Form submissions are delivered through Nextly's own webhooks: subscribe an endpoint to the form.submission.created event. Its payload says which form, when, and the submission's status, and its resource.id names the submission (resource.slug is the submissions collection, form-submissions unless you renamed it). It never carries the answers, the IP address or the user agent, so a receiver that needs them reads the submission by that id:
- A receiver in another system calls the REST API:
GET /admin/api/collections/form-submissions/entries/{id}, authenticated with an API key that may read submissions (read-form-submissions). - A receiver inside the same Next.js app can use the Direct API instead.
Verify each delivery's signature as the Webhooks page describes, before reading anything.
A form has no webhook settings of its own, and the submissions collection's default entry.* events are switched off, so an endpoint subscribed to entry.created or * does not receive the answers either.
Exporting Submissions
Export submissions to CSV or JSON from code or the admin UI. The examples below assume you already have form (a FormDocument) and submissions (a SubmissionDocument[]) -- for example, from ctx.services.collections in a plugin route or hook.
CSV Export
import { exportToCSV, downloadFile, generateExportFilename } from '@nextlyhq/plugin-form-builder';
import type { FormDocument, SubmissionDocument } from '@nextlyhq/plugin-form-builder';
declare const form: FormDocument;
declare const submissions: SubmissionDocument[];
const csv = exportToCSV(submissions, form, {
includeMetadata: true,
delimiter: ',',
includeBOM: true, // Excel compatibility
dateFormat: 'iso',
});
// Browser download
const filename = generateExportFilename('contact', 'csv');
// "contact-submissions-2026-03-20.csv"
downloadFile(csv, filename, 'text/csv;charset=utf-8');JSON Export
import { exportToJSON } from '@nextlyhq/plugin-form-builder';
import type { FormDocument, SubmissionDocument } from '@nextlyhq/plugin-form-builder';
declare const form: FormDocument;
declare const submissions: SubmissionDocument[];
const json = exportToJSON(submissions, form, {
includeMetadata: true,
includeFormDefinition: true,
indent: 2,
});One-Step Export
import { exportAndDownload } from '@nextlyhq/plugin-form-builder';
import type { FormDocument, SubmissionDocument } from '@nextlyhq/plugin-form-builder';
declare const form: FormDocument;
declare const submissions: SubmissionDocument[];
// In a React component
const handleExport = (format: 'csv' | 'json') => {
exportAndDownload(submissions, form, format);
};Example: Contact Form
A complete contact form setup from config to submission handling.
1. Configure the plugin:
// nextly.config.ts
import { defineConfig } from 'nextly';
import { formBuilder } from '@nextlyhq/plugin-form-builder';
const fb = formBuilder({
notifications: {
defaultFrom: 'noreply@mysite.com',
},
spamProtection: {
honeypot: true,
rateLimit: { maxSubmissions: 5, windowMs: 60_000 },
},
});
export default defineConfig({
plugins: [fb.plugin],
collections: [Posts, Users, Media],
});2. Define the form fields (code-first):
import {
text, email, textarea, select, checkbox, option, hidden,
createFormConfig,
} from '@nextlyhq/plugin-form-builder';
const contactForm = createFormConfig('contact', [
text({ name: 'name', label: 'Full Name', required: true }),
email({ name: 'email', label: 'Email', required: true }),
select({
name: 'subject',
label: 'Subject',
required: true,
options: [
option('General Inquiry'),
option('Support'),
option('Partnership'),
],
}),
textarea({
name: 'message',
label: 'Message',
required: true,
rows: 5,
validation: { maxLength: 5000 },
}),
checkbox({ name: 'newsletter', label: 'Subscribe to updates' }),
hidden({ name: 'source', label: 'Source', defaultValue: 'website' }),
]);createFormConfig builds the definition and stops there. Nothing in the plugin accepts
a FormConfig: formBuilder() takes no forms option, and submitForm resolves a slug
by querying the forms collection, so a form exists at submission time only as a row in
that collection. Create the contact form in the admin under Forms before going further,
or seed the row yourself; a submission to a slug with no row is answered as a form that
does not exist, before validation or storage. Closing that gap is tracked as
createFormConfig having no path to a row.
3. Handle submissions in an API route:
submitForm takes two context values: pluginContext, which is a plugin's ctx, and
pluginConfig, which is the resolved form-builder config. Both are reachable from your
own config file. A route contributed through contributes.routes
is handed the ctx as its second argument, and formBuilder() returns its resolved
config as fb.config, which is why step 1 keeps the return value rather than inlining it.
So the endpoint is a small plugin of your own, declared beside the form-builder in
nextly.config.ts:
// nextly.config.ts
import {
defineConfig,
getTrustedClientIp,
parseTrustedProxyIpsEnv,
} from "nextly";
import { definePlugin } from "@nextlyhq/plugin-sdk";
import { formBuilder, submitForm } from "@nextlyhq/plugin-form-builder";
const fb = formBuilder({
notifications: { defaultFrom: "noreply@mysite.com" },
spamProtection: {
honeypot: true,
rateLimit: { maxSubmissions: 5, windowMs: 60_000 },
},
});
const contactEndpoint = definePlugin({
name: "@acme/contact-endpoint",
version: "1.0.0",
nextly: ">=0.0.2-alpha.63",
contributes: {
routes: [
{
method: "POST",
path: "/contact",
// A visitor has no session, so the route opts out of auth. What stops
// abuse is `submitForm` itself: the honeypot and rate limit configured
// above run on every call, and a route that skipped it would skip them.
public: true,
handler: async (request, ctx) => {
const data = await request.json();
// Through the trust-proxy gate, never straight off the header: this
// value keys the rate limit above, so a forgeable one hands a bot a
// fresh bucket per request. `null` becomes `undefined`, which the
// limiter reads as "cannot attribute" and skips.
const ipAddress =
getTrustedClientIp(request, {
trustProxy: ctx.config.security?.trustProxy ?? false,
trustedProxyIps: parseTrustedProxyIpsEnv(
process.env.TRUSTED_PROXY_IPS
),
}) ?? undefined;
const result = await submitForm(
{
formSlug: "contact",
data,
metadata: {
ipAddress,
userAgent: request.headers.get("user-agent") ?? undefined,
},
},
{ pluginContext: ctx, pluginConfig: fb.config }
);
return Response.json(result);
},
},
],
},
});
export default defineConfig({
plugins: [fb.plugin, contactEndpoint],
// collections: [...] as in step 1
});The route is mounted under the contributing plugin's namespace, so the form posts to
/admin/api/plugins/@acme/contact-endpoint/contact in a scaffolded app, where /admin/api
is wherever createDynamicHandlers is mounted.
Next Steps
- Plugins Overview -- how the plugin system works and how to create custom plugins
- Email Configuration -- set up email providers and templates for form notifications
- Collections -- understand how plugin collections work alongside your own