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:

  1. Takes a field definition array, the field key, and optional value/store/source parameters
  2. Spreads the entire field definition into _f.* via set_variable_dot('_f', $def) - every .meta attribute is automatically available in the template
  3. Sets computed variables on top: _f.key, _f.title, _f.value, _f.model, _f.required, _f.ai, _f.bg
  4. 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

  1. Create the template:

    core/modules/forms/tpl/field-{type}/index.tpl   ← core types
    ext/tpl/field-{type}/index.tpl                   ← project-specific types
    
  2. 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 HTML name attribute
    • Access type-specific options via _f.* (e.g. [#_f.accept#] for file restrictions)
  3. Register the type name in .meta - it is immediately usable once the template exists.

  4. If the field manages complex state (e.g. a file picker, a media browser trigger), nest its own x-data inside the form's x-data. The outer form_data remains 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.

View this page as Markdown