# 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 `@public` surface becomes semver-protected at 1.0 (see [API stability](https://nextlyhq.com/docs/plugins/stability.md)). 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

```bash
npm install @nextlyhq/plugin-form-builder
```

The 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

```typescript
// 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.

```typescript
// 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:

```typescript
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:

```typescript
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.

```typescript
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:

```typescript
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:

```typescript
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](https://nextlyhq.com/docs/plugins/routes.md)'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.

```typescript
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:

```ts
// 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](https://zod.dev) 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](https://nextlyhq.com/docs/plugins/routes.md)).

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:

```typescript
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

```typescript
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](https://nextlyhq.com/docs/guides/email.md)) |
| `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:

```typescript
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.

```typescript
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.

> [!WARNING]
> **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.

```typescript
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.

```typescript
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](https://nextlyhq.com/docs/webhooks.md): 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](https://nextlyhq.com/docs/api-reference/rest-api.md): `GET /admin/api/collections/form-submissions/entries/{id}`, authenticated with an [API key](https://nextlyhq.com/docs/api-reference/rest-api.md#api-key-server-to-server) that may read submissions (`read-form-submissions`).
* A receiver inside the same Next.js app can use the [Direct API](https://nextlyhq.com/docs/api-reference/direct-api.md) 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

```typescript
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

```typescript
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

```typescript
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:**

```typescript
// 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):**

```typescript
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`](https://nextlyhq.com/docs/plugins/routes.md)
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`:

```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](https://nextlyhq.com/docs/plugins.md) -- how the plugin system works and how to create custom plugins
* [Email Configuration](https://nextlyhq.com/docs/guides/email.md) -- set up email providers and templates for form notifications
* [Collections](https://nextlyhq.com/docs/configuration/collections.md) -- understand how plugin collections work alongside your own
