You're reading docs for Nextly Alpha. APIs may change between releases.

Plugins

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 API surface is now stable and semver-protected (see API stability); the plugin packages themselves are still 0.x alpha, so pin your versions 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-builder

The plugin requires these peer dependencies (already present in most Nextly projects):

  • nextly >= 0.0.13
  • @nextlyhq/admin >= 0.0.13
  • react ^18 or ^19
  • next ^14, ^15, or ^16

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:

CollectionDefault SlugPurpose
FormsformsStores form definitions (fields, settings, notifications)
Submissionsform-submissionsStores 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: ({ user }) => !!user,
  },
}

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.

TypeDescriptionKey Options
textSingle-line text inputminLength, maxLength, pattern
emailEmail address with format validationpattern
numberNumeric inputmin, max, step
phonePhone number inputpattern
urlURL with format validationpattern
textareaMulti-line text inputrows, minLength, maxLength
selectDropdown menuoptions, allowMultiple
checkboxSingle boolean toggledefaultValue
radioRadio button groupoptions
fileFile uploadaccept, multiple, maxFileSize
dateDate pickermin, max
timeTime pickerdefaultValue (HH:mm format)
hiddenHidden 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!' },
    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. Free-text fields are also sanitized by stripping HTML tags to prevent injection.

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:

StatusDescription
newUnread submission
readReviewed by an admin
archivedArchived (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:

SettingDescription
templateSlugEmail template to use (from your email configuration)
recipientType"static" (fixed email) or "field" (value from a form field)
toEmail address, or {{fieldName}} reference when using field recipient
providerIdEmail provider to use (optional, falls back to system default)
cc / bccAdditional 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 silently rejected -- the bot receives a fake success response to avoid revealing the detection.

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 store is in-memory. For multi-instance deployments, consider using a shared store (Redis) or adjusting thresholds.

reCAPTCHA v3

Configuration is available for Google reCAPTCHA v3. Set the site key and secret key globally or per form.

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

Forms support webhook integrations that fire on submission events. Webhooks are asynchronous -- they do not block the submission response. Failed webhooks are logged but do not cause submission failures.

// In a form's notification config
webhooks: [
  {
    url: 'https://api.example.com/webhooks/form-submissions',
    events: ['submission.created'],
    secret: 'your-webhook-secret',
    includeData: true,
  },
  {
    url: 'https://crm.example.com/api/leads',
    events: ['submission.created', 'submission.updated'],
    headers: { 'X-API-Key': 'abc123' },
    method: 'POST',
  },
]

Supported events: submission.created, submission.updated, submission.deleted.

When a secret is configured, the request includes an X-Webhook-Signature header with an HMAC-SHA256 signature (sha256=<hex>). Verify it on the receiving end:

// app/api/form-webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  // Fail closed on a missing secret. Defaulting to "" still produces a valid
  // HMAC, and it is one anybody can compute, so the endpoint would authenticate
  // every forged request instead of none.
  const secret = process.env.FORM_WEBHOOK_SECRET;
  if (!secret) {
    return new Response("Webhook secret is not configured", { status: 500 });
  }

  // The RAW body, read once. Signing a re-serialised object instead compares a
  // signature against bytes the sender never sent: `JSON.parse` then
  // `JSON.stringify` is free to reorder keys and drop whitespace, so a correct
  // signature fails whenever the two disagree.
  const raw = await request.text();
  const expected = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;
  const sent = request.headers.get("x-webhook-signature") ?? "";

  // Lengths first: `timingSafeEqual` throws on a mismatch, which turns a forged
  // signature into a 500 rather than a rejection.
  const a = Buffer.from(sent);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload: unknown = JSON.parse(raw);
  // ... handle the verified payload

  return Response.json({ received: true });
}

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 /api/plugins/@acme/contact-endpoint/contact.

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