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:
- Create a target resource for submitted records, e.g.
ext/data/questionnaire_entries/.meta. - Create the form definition JSON next to the route, e.g.
questionnaire.json. - Render it with
[#module forms#]and[#build-form questionnaire#]. - Open only anonymous
POSTfor the target resource:
[#module api#]
[#api-allow post questionnaire_entries#]
- 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_EXISTSto the same success state asRESOURCE_CREATED.