Reference
Field types
The supported field types and their configuration.
Field types
| Type | Description |
|---|---|
text |
Single-line text |
textarea |
Multi-line plain text |
html |
Rich text (bubble editor and field bar, see Editor buttons) |
slug |
URL-safe slug field computed from configured source fields |
boolean |
True/false toggle |
date |
Date picker |
email |
Email address |
url |
URL |
password |
Encrypted password field |
image |
Single image upload |
file |
Single file upload |
upload |
Generic upload |
gallery |
Multiple image upload |
select |
Dropdown - fixed options or from resource |
number |
Numeric input - supports optional min and max |
color |
Color picker |
location-picker |
Interactive world map that stores latitude in the field and longitude in the configured longitude_field companion field |
Field type names must match exactly.
Field configuration
Common options per field:
| Key | Type | Description |
|---|---|---|
name |
string | Display label in admin |
required |
boolean | Validation - must be set on all title/name fields |
admin_col |
boolean | Show in admin overview table (default: true) |
admin_filter |
boolean or object | Add an admin overview filter for a select or boolean field. Use { "default": <stored-value> } to enable it with a default selection, and optional "feature": "permission-name" to show it only to users with that feature. Independent from admin_col. |
multi |
boolean | Allow multiple values |
i18n |
boolean | Translate per language |
accept |
string | File type restriction for image/file fields |
HTML fields must explicitly use one of two configurations:
Simple (short text blocks, e.g. intro):
"buttons": "bold,italic",
"admin_col": false
Rich (full content, e.g. body):
"buttons": "h2,h3,h4,bold,italic,orderedlist,unorderedlist,quote,anchor",
"media": true,
"media_sizes": "sm-90,md-70,lg-60,xl-50,xxl-40",
"admin_col": false
Never define an html field without choosing one of these. media_sizes is required when media: true.
Editor buttons. buttons lists the formatting buttons of an html field, in order. Without buttons a field gets bold,italic,removeFormat. Built in:
| Button | Does |
|---|---|
bold, italic |
Inline formatting (Ctrl/Cmd+B, Ctrl/Cmd+I) |
h2, h3, h4, quote, pre |
Block format; again turns it back into a paragraph |
orderedlist, unorderedlist |
Lists |
anchor |
Link: add, edit (prefilled) or remove; Ctrl/Cmd+K |
removeFormat |
Clear formatting |
strikethrough, subscript, superscript, underline |
Optional, in no default set |
The same buttons show in two places: the bubble toolbar on selected text, and the field bar. In admin forms the field bar is part of the field box; on inline editing it floats above the focused field and also holds Media (when media: true) and Save (Ctrl/Cmd+S). Alt+F10 moves keyboard focus to the field bar. Pasting keeps only plain text unless the field sets "paste_html": true, which keeps basic formatting and strips Word/Google Docs markup.
Custom editor buttons. A project declares its own buttons as JSON in ext/tpl/bubble-editor-buttons/index.tpl (the template may use shortcodes, e.g. [#text ...#] for labels), then lists them in a field's buttons like the built-in ones:
{
"highlight": { "kind": "wrap", "tag": "mark", "label": "[#text Highlight#]", "icon": "M" },
"h5": { "kind": "block", "tag": "h5", "label": "Heading 5", "icon": "H5" },
"poi": {
"kind": "event", "event": "nb:insert-point", "insert": true, "bubble": true,
"active": "[data-poi-link]", "label": "[#text Point of interest#]", "icon": "<svg ...></svg>"
}
}
| Kind | Does |
|---|---|
command |
A formatting command ("command": "bold") |
block |
Block format ("tag": "h5") |
list |
List command |
wrap |
Wraps the selection in an element ("tag", optional "class"); again unwraps it |
insert |
Inserts fixed HTML at the caret ("html") |
event |
Fires a DOM event on the field ("event", bubbling, detail: { editor, range }) for the project's own code, e.g. to open a picker |
link |
URL prompt, like anchor |
Options: "insert": true puts a button next to Media in the field bar instead of with the formatting buttons; add "bubble": true when it also acts on selected text; "active" is a selector that marks the button active when the caret is inside a match. Links are a[href]: a project may use <a> without href for its own inline elements. Code can also register buttons with nb.bubble_editor.register(name, { label, icon, run(ctx), is_active(ctx) }).
A project's editing tools (the modal and script behind an event button) go in ext/tpl/edit-extras/index.tpl, which renders for users who can edit inline, on site and admin pages.
Slug fields:
Slugs are explicit fields. Do not add "slug": true to another field. Define a separate field with "type": "slug" and a source that points
to the field or fields it is derived from.
The admin form computes the slug in the browser from its source fields before submit. The API does not invent missing slug fields on save; it stores the slug field value it receives. If code creates or imports records outside the admin form, it must also set the slug field value.
Set "preserve_existing": true on the slug field to generate missing slugs while
keeping saved URLs unchanged when the form opens or its source title changes.
Editors can still change a saved slug explicitly.
"url_slug": {
"name": "URL slug",
"type": "slug",
"source": "title",
"admin_col": false
}
Multiple sources are comma-separated - values are joined with a space before slugifying:
"url_slug": {
"name": "URL slug",
"type": "slug",
"source": "title,date",
"admin_col": false
}
With "source": "title,date" and title "Jazz Night", date "2026-04-09", the slug becomes jazz-night-2026-04-09. Always add url_slug to the resource index array so it can be looked up in route.inc.
If the source field is i18n, the slug field is treated as i18n too. For example,
title.nl, title.en, and title.fr produce matching values under
title_slug.nl, title_slug.en, and title_slug.fr. Do not hand-translate
slugs; let the slug field derive them from the language-specific source values.
Select fields:
Fixed options:
"type": "select",
"options": {
"new": "New",
"active": "Active",
"closed": "Closed"
}
Options from another resource:
"type": "select",
"resource": "categories"
Admin overview filters are opt-in for select and boolean fields:
"status": {
"type": "select",
"options": { "active": "Active", "closed": "Closed" },
"admin_filter": { "default": "active" }
},
"published": {
"type": "boolean",
"admin_col": false,
"admin_filter": true
}
Filters compare raw stored values while displaying configured option or related
resource labels. Multi-value selects match when the record contains the selected
value. Multiple filters combine with AND logic and continue to work with search,
sorting, and pagination. Selections persist in the URL as
filter[field]=value. An absent parameter uses the configured default; an empty
parameter explicitly selects All. Invalid URL values normalize to All.
Location picker fields:
Use location-picker when editors should choose coordinates on a map instead
of typing them. The picker field stores latitude in its own field and updates a
separate numeric longitude field named by longitude_field. Hide the companion
field with wrapper_class: "hidden" so the editor sees one atomic control while
the resource retains simple scalar coordinates:
"location_lat": {
"name": "Location",
"type": "location-picker",
"longitude_field": "location_lng",
"required": true,
"admin_col": false
},
"location_lng": {
"name": "Longitude",
"type": "number",
"required": true,
"min": -180,
"max": 180,
"wrapper_class": "hidden",
"admin_col": false
}
Form field rendering pipeline
This section explains the full flow from a resource field definition to a rendered form input. Read this before building a new field type.
Flow overview
.meta field definition
↓
render_field() - PHP (core/modules/forms/lib/render-field.php)
↓
_f.* template variables
↓
[#field-{type}#] template (core/modules/forms/tpl/field-{type}/index.tpl)
↓
Alpine.js form_data.{field} ← x-model binding
↓
API POST/PUT on submit
render_field() and the _f.* context
render_field() is the gateway. It:
- Takes a field definition array, the field key, and optional value/store/source parameters
- Spreads the entire field definition into
_f.*viaset_variable_dot('_f', $def)- every.metaattribute is automatically available in the template - Sets computed variables on top:
_f.key,_f.title,_f.value,_f.model,_f.required,_f.ai,_f.bg - Dispatches to
[#field-{type}#]- the template for that field type
_f.model is the Alpine.js x-model expression, computed as {store}.{field}. The default store is form_data. For i18n fields it becomes form_data.{field}[{lang}].
Standard _f.* variables available in every field template:
| Variable | Contents |
|---|---|
_f.key |
Field name / HTML name attribute |
_f.title |
Display label (from name in .meta) |
_f.value |
Pre-populated value |
_f.model |
Alpine x-model expression, e.g. form_data.email |
_f.required |
Whether the field is required |
_f.bg |
Background color class for the floating label |
_f.* (any) |
All other field definition keys - _f.accept, _f.options, _f.resource, etc. |
form_data: the Alpine.js store
form_data is the reactive Alpine.js object that holds all current field values. It is declared on the <form> element. Each field writes its value here via x-model="[#_f.model#]".
On submit, form_data is spread into the API payload and POSTed to /api/v1/{resource}. Rich editor fields (HTML, gallery, image) write their values through a separate mechanism (nb.edit.get_field_values()), which is merged in automatically - field templates do not need to handle this.
The simplest field template
field-default/index.tpl is the base for text-like fields:
<div class="[#_f.wrapper_class#]">
<input type="[#_f.type#]" value="[#_f.value#]" name="[#_f.key#]"
x-init="[#_f.model#]=`[#_f.value#]`"
x-model="[#_f.model#]"
[#if _f.required=(not-empty) echo=required#]
class="input input-bordered w-full" />
<label class="pointer-events-none absolute left-3 -top-2.5 px-1
font-bold text-sm leading-tight [#get _f.bg default=bg-neutral-50#]">
[#_f.title#][#if _f.required=(not-empty) echo=" *"#]
</label>
</div>
Adding a new field type
-
Create the template:
core/modules/forms/tpl/field-{type}/index.tpl ← core types ext/tpl/field-{type}/index.tpl ← project-specific types -
In the template:
- Bind to Alpine via
x-model="[#_f.model#]" - Seed the initial value with
x-init="[#_f.model#]='[#_f.value#]'" - Use
[#_f.key#]as the HTMLnameattribute - Access type-specific options via
_f.*(e.g.[#_f.accept#]for file restrictions)
- Bind to Alpine via
-
Register the type name in
.meta- it is immediately usable once the template exists. -
If the field manages complex state (e.g. a file picker, a media browser trigger), nest its own
x-datainside the form'sx-data. The outerform_dataremains accessible from nested scopes.
1.1 custom fields must use the field-{type} template convention and the
_f.* context (_f.model, _f.key, _f.title, _f.bg, etc.). Older
templates that use _fmodel, _fid, _ftitle, _fbg,
_bf_render_field(), or Tailwind Elements data-te-* attributes are legacy
and should be rebuilt instead of carried forward.
Nested and repeater fields must explicitly manage nested Alpine model paths.
For example, a group field that stores locations should bind child fields
to paths such as form_data.locations[ix].location or pass an explicit model
override when reusing render_field(). Do not assume the core renderer can
infer nested array paths from a flat field key.