Guides

Forms

Define forms, validation and submission handlers.

Forms

The forms module handles front-end form submissions with CSRF protection, honeypot spam filtering, validation, and Alpine.js-powered submission via the REST API.

Rendering a form: [#build-form name#]

[#build-form contact#] reads contact.json from the same directory as the current route and renders a complete form. The form submits via Alpine.js to the API - no page reload.

Form definition file (ext/uri/contact/contact.json):

{
  "name": "contact",
  "resource": "leads",
  "success_message": "Thank you, we will be in touch.",
  "fields": {
    "name": {
      "type": "text",
      "name": "Your name",
      "required": true
    },
    "email": {
      "type": "email",
      "name": "Email address",
      "required": true
    },
    "message": {
      "type": "textarea",
      "name": "Message"
    }
  },
  "buttons": [
    { "type": "submit", "title": "Send" }
  ]
}

The resource field is the target resource that will receive the new record via the API. Make sure that resource exists in ext/data/ with a matching .meta.

Form styling hooks

[#build-form#] adds stable classes to the rendered form and field wrappers. By default the class suffix is the form definition's name:

<form class="nb-form nb-form-contact ...">

Each rendered field wrapper receives:

<div class="nb-field nb-form-field-contact relative my-10">

Set class_name in the form JSON to share one styling hook across multiple forms. The value is treated as a class suffix, not a full class list:

{
  "name": "contact",
  "class_name": "truesim",
  "resource": "leads",
  "fields": {}
}

This renders nb-form-truesim on the form and nb-form-field-truesim on each field wrapper. The default field wrapper style remains relative my-10.

To add classes for a specific render, set form-field-wrapper-class before calling [#build-form#]:

[#set form-field-wrapper-class="my-2"#]
[#build-form contact#]

Or pass the class directly to the shortcode:

[#build-form contact field-wrapper-class="my-2"#]

These classes replace the default wrapper style slot (relative my-10). The generated hook classes nb-field and nb-form-field-{suffix} are always kept. With field-wrapper-class="my-2", the field wrapper becomes:

<div class="nb-field nb-form-field-contact my-2">

For a public form, explicitly allow the API POST with a route such as:

ext/uri/api/v1/leads/index.tpl
[#api-allow post leads#]

This allows anonymous submissions for that one method/resource pair while keeping the rest of the API protected.

Public submission form checklist

Contact forms, questionnaires, signup forms, and lead forms all use the same resource-write pattern:

  1. Create a target resource for submitted records, e.g. ext/data/questionnaire_entries/.meta.
  2. Create the form definition JSON next to the route, e.g. questionnaire.json.
  3. Render it with [#module forms#] and [#build-form questionnaire#].
  4. Open only anonymous POST for the target resource:
[#module api#]
[#api-allow post questionnaire_entries#]
  1. If the submission should send an email or trigger other side effects, attach an explicit create event to the target resource .meta:
"events": {
  "create": ["questionnaire-entry-created"]
}

Each submission is stored as a normal record in the target resource and appears in the admin like any other resource record.

Field types in forms

Same type names as resource fields: text, textarea, email, url, select, upload. Add "help": "Hint text" to any field for a help label below the input.

Select field:

"status": {
  "type": "select",
  "name": "Type",
  "options": {
    "general": "General question",
    "support": "Support request"
  }
}

Upload field:

"attachment": {
  "type": "upload",
  "name": "Attachment",
  "accept": ".pdf,.doc"
}

For forms with file upload, also set "upload_field": "attachment" at the root of the form definition.

Grouping fields

Use group_start / group_end to visually group fields side by side:

"fields": {
  "_g1": { "type": "group_start", "name": "Name" },
  "first_name": { "type": "text", "name": "First name" },
  "last_name":  { "type": "text", "name": "Last name" },
  "_g1e": { "type": "group_end" }
}

CSRF and honeypot

Both are handled automatically by [#build-form#]. The form includes a hidden form_key (session-matched token) and a honeypot field. No extra configuration needed.

Custom form handlers

For forms that need server-side processing beyond writing to a resource (e.g. file imports or enqueueing a background job), add handler files alongside the route:

ext/uri/contact/contact.json       ← form definition
ext/uri/contact/post_contact.inc   ← runs on submission
ext/uri/contact/validate_contact.inc  ← runs before post, return false to abort

The files are named post_{name}.inc and validate_{name}.inc, where {name} matches the form's name field. Both are optional - use one or both as needed.


Frontend forms

For simple public forms, prefer Alpine.js with nb.api.post() or build-form over route post_*.inc.

  • Normalize values in the frontend when the stored value should be canonical, for example trim().toLowerCase() for email.
  • If duplicate submissions should be treated as success in the UX, map RESOURCE_EXISTS to the same success state as RESOURCE_CREATED.

View this page as Markdown