---
title: Custom fields
description: Every field type you can declare on a content-type, page-type, collection-type or global field, with its config keys, write format and template output.
updated: 2026-06-22
---

Fields are declared inside a type's `tabsets.<key>.fields` map. Each field has a `type` and an optional `config` object. This page catalogues every documented type: the [standard types](#standard-types), the [special types](#special-fields) that drive the form pipeline and remote data, the [validators](#validators) used inside `validate`, and the [image, date and wysiwyg](#image-date-and-wysiwyg-config) config notes.

The catalogue covers the **20 documented standard types** plus **6 special types** (26 documented entries). For where each type may be declared, see [content-types](/en/docs/content-types), [itemset-types](/en/docs/itemset-types) and the [content model](/en/docs/content-model).

<div class="q-ph"><i class="fa-solid fa-terminal"></i><span><b>Placeholder:</b> the skill header claims "26 standard + 6 special", but only 20 distinct standard types are documented. To reconcile the exact 26: grep the engine field-type registry in <code>quilium-frontend</code> / <code>quilium-cms</code> source.</span></div>

## Common options

These keys are valid on **every** field, regardless of type.

| Key | Default | Notes |
|---|---|---|
| `label` | — | Required. Backoffice field label. |
| `info` | — | Helper text under the field. |
| `required` | `false` | Marks the field as mandatory in the editor. |
| `translatable` | `true` | Set `false` for slugs, anchors, CSS classes, hex values. See [the multilingual model](/en/docs/multilingual-model). |
| `default` | — | Initial value. Config/structural fields only (see per-type notes). |
| `placeholder` | — | Placeholder text in the input. |
| `fieldset` | — | Assigns the field to a visual `fieldset` group declared on the type. |

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Write format: two shapes, not one.</b> <b>Media fields</b> — <code>image</code>, <code>file</code> — take an <b>array of objects</b>, <code>[{id:'&lt;uuid&gt;'}]</code>, because the stored value really is an array of media objects: the server hydrates <code>name</code>, <code>alt</code>, <code>url</code> and <code>versions</code> back onto it when you read it. <b>Every reference by id</b> — <code>items</code>, <code>smartselect</code> with <code>source: items</code>, <code>page</code> — takes a <b>flat array of id strings</b>, <code>['&lt;uuid&gt;']</code>. Either way, omitting one of these fields on an update can clear it, so resend the ids.</div></div>

Sending the object shape to an id reference is not rejected today: the server normalises it to flat ids on write *and* on read, on all three, so rows a previous client stored that way repair themselves. Write the flat shape anyway — the normalisation is a safety net for legacy data, not the contract, and every other layer (the backoffice field, the `where` filters, the index) is built on flat ids. It matters because of what the object shape used to do before that net existed: on `items` it corrupted silently, the listing showing the right label while the edit form's select came back empty and `where` never matched; on `page` a PUT resolved the objects to nothing and **emptied the field on a 200**. If you inherit a site where a relation holds data but the backoffice select is empty, rewrite that field once in the flat shape.

## Standard types

Twenty documented standard types. Fields not exercised on the live fil-rouge site are flagged below.

| Type | Key config keys | Notes |
|---|---|---|
| `text` | `translatable`, `placeholder`, `info` | Titles, anchors, CSS classes, URLs, FA icon names. Template output is auto-escaped. |
| `textarea` | — | Meta-description, excerpt, multi-line plain text. Auto-escaped, never piped through `safe`. |
| `wysiwyg` | `config.limiter` (int char cap); `required`, `info`, `translatable` | The only documented per-field keys are `label` / `required` / `info` / `translatable` / `config.limiter`. Toolbar, allowed tags, formats and link target are **not** YAML-configurable. The styles palette is site-wide ([`miscellaneous.wysiwyg.styles`](/en/docs/site-settings#wysiwyg-styles)). Renders as HTML. |
| `json` | — | Structured editable object. Use `info:` to document the shape; invalid JSON breaks render. *Skill-sourced, not live-confirmed.* |
| `embed` | — | Raw HTML / iframe / `<script>`. Renders unescaped. XSS vector. *Skill-sourced, not live-confirmed.* |
| `number` | — | Filterable and sortable numeric. Not for phone numbers (leading-zero loss). |
| `slug` | `config.linkedField` | URL segment, unique, one per type. Per-locale slugs when `translatable`. |
| `checkbox` | `config.checkedValue`, `config.uncheckedValue` | Persists strings instead of a boolean. Allowed as a collection `filter`. |
| `select` | `config.options` (key:Label map), `config.multiple`, `config.mustMatch`, `config.inlineEditable`, `config.bulkEditable`, `default` (an existing key) | Static enum, keep to 8 or fewer. `inlineEditable: true` makes the value editable directly in the listing cells; `bulkEditable` opts into [bulk edit](/en/docs/itemset-types#bulk-edit). |
| `smartselect` | `config.source` = `static` \| `items` \| `api` (per-mode keys below) | Dropdown sourced from a static map, a collection, or a remote API. In `source: items` mode it is an id reference — **write a flat `['&lt;uuid&gt;']`**. *Skill-sourced, not live-confirmed.* |
| `items` | `config.itemSet` (req), `config.fieldLabel`, `config.multiple`, `config.autocomplete`, `config.sort`, `config.where`, `config.firstValueIsEmpty` | Pick item(s) from a collection. **Write a flat `['&lt;uuid&gt;']`** — an id reference, not a media field. |
| `color` | `config.options` (keys = valid CSS color values, values = labels) | Brand palette picker. |
| `page` | `config.multiple`, `config.pageTypes`, `config.navigations` | Internal page link. **Write a flat `['&lt;uuid&gt;']`.** Omitting the field on update can clear it, resend ids. |
| `image` | `config.versions.<key>.{label, width, height, quality, resolutions}`, `config.multiple`, `config.max`, `config.onDelete` (`keep`/`cascade`/`orphan`) | **At least one `versions.<key>` is required.** **Write `[{id:'&lt;media-uuid&gt;'}]`.** See [image versions](#image-date-and-wysiwyg-config). `onDelete`: media fate when the item is trashed — see [ItemSet types → Retention](/en/docs/itemset-types). |
| `file` | `config.multiple`, `config.max`, `config.onDelete` (`keep`/`cascade`/`orphan`) | PDF, CV, audio. **Write `[{id:'&lt;media-uuid&gt;'}]`**, like `image`. `onDelete`: media fate when the item is trashed — see [ItemSet types → Retention](/en/docs/itemset-types). *Skill-sourced, not live-confirmed.* |
| `repeat` | `config.limit`, `config.fields` (sub-fields; first must be a stable text/wysiwyg label) | Two to ten identical structured items inline. |
| `choice` | `config.choices.<key>.{name, fields}` | Flow of heterogeneous typed blocks. Discriminator `_choice` (see note). Each variant has its own fields and template. |
| `date` | `default: now` | Single day `YYYY-MM-DD`. Render with `\| date: 'DD MMMM YYYY'`. |
| `calendar` | RRULE value object: `start`, `end`, `freq` (`DAILY`/`WEEKLY`/`MONTHLY`/`YEARLY`/`''`), `interval`, `byday[]`, `count` | Recurring events. `translatable` defaults to **false**. See [date and calendar](#image-date-and-wysiwyg-config). *Skill-sourced, not live-confirmed.* |
| `geo` | `config.multiple` | Latitude/longitude. Template reads `.lat` / `.lng`. *Skill-sourced, not live-confirmed.* |

**`smartselect` per-mode config keys:**

| `config.source` | Keys |
|---|---|
| `static` | `options`, `multiple`, `mustMatch`, `inlineEditable`, `bulkEditable` |
| `items` | `itemSet`, `fieldLabel`, `autocomplete`, `sort`, `where` |
| `api` | `format`, `src`, `fieldLabel`, `fieldValue`, `searchParamName`, `auth`, `headers` |

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b><code>_choice</code> vs <code>_type</code> discriminator.</b> Use <code>_choice</code> to discriminate a <code>choice</code> variant in templates. One skill's example shows <code>_type</code>, which is treated as a mistake, the dedicated pattern skill is authoritative and flags <code>_type</code> as wrong. Not exercised live on the fil-rouge site.</div></div>

## Special fields

Form-pipeline and data-fetch types. Declared like any other field, usually one per tabset stage on a form-handling content-type.

| Type | Key config keys | Notes |
|---|---|---|
| `query` | `itemSet` (req), `limit` (literal int 1..100), `page:'{{query.page}}'`, `defaults`, `sort` (list of single-key maps), `with`, `dependsOn`, `where` | Render-time collection fetch. `{{query.limit}}` does **not** substitute, only `{{query.page}}` does. Pagination capped at 100, enumerate `?page=N`. Heavily used on the live site. See [template variables](/en/docs/template-variables). |
| `validate` | `config.rules.<field>: [rule…]`, `config.redirect` (array of one page-id string `["&lt;uuid&gt;"]`) | Form validation. Errors at `content.form.<field>.errors` (object `{rule:msg}`). Posted values at `request.body.<field>`. Success is a 302 PRG redirect to the target. `to`/`subject`/`replyTo` do **not** live here. |
| `sendmail` | `config.template`, `config.attachments`, `config.smtp{host, port, secure, username, password, from}`, `config.unflatten` | **`to` / `subject` / `replyTo` are per-instance content-block fields, not config** — the schema refuses them outright (*"A Sendmail field can't have a 'to' property."*); set via the backoffice or `update-content custom.<field>.{to,subject,replyTo}`, interpolating `{{name}}` **tight-braces**. `From` is settable only through `config.smtp.from`. Email template reads posted fields at root `{{name}}`, block fields via `_content.custom.<f>` — `form.<f>` renders empty. *Schema confirmed live; delivery not exercised (no mailbox on the demo sites).* |
| `save` | `config.key` (target collection, req), `config.attachments`, `config.path`, `config.updateField` (upsert) | Persist to a collection. `default: now` is **not** honored, use the system `created_at`. Live example: `saveToCollection: { type: save, config: { key: abonnes } }`. |
| `http` | `config.src`, `config.method` (`POST`/`PUT`/`PATCH`), `config.timeout` (≤60) | Forward a submission to a webhook. Response is **not** exposed to templates. *Skill-sourced, not live-confirmed.* |
| `api` | `config.format:'json'`, `config.src`, `config.caching{enabled, validity}` (validity in minutes, default 20), `config.timeout` (≤60), `config.defaults`, `config.headers`, `config.auth{user, password}` | Render-time remote JSON fetch, exposed as the parsed object. **`src` resolves in the same variable context as a `query`'s `where`** — including a sibling `query` field's result, reached by its **bare field name** (`{{itinerary.coords.lat}}`; the indexed and `field.`-prefixed forms both fail silently). `dependsOn` is refused on an `api` field. A failed fetch — or a mistyped variable — yields an **empty object** with the page still at 200, so guard on the branch you loop. A dead endpoint costs the full `timeout` on every cache miss. See [Consume an external feed](/en/docs/external-feed). |

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Live-confirmed vs skill-sourced.</b> <code>query</code>, <code>validate</code>, <code>save</code> and <code>api</code> are exercised on live demo sites. <code>sendmail</code>'s config keys were validated against a live schema, but no message was ever sent — the demo sites have no mailbox. <code>http</code> remains skill-sourced.</div></div>

## Where-clause operators

The vocabulary a `query` accepts under `where:`. It is shared by `items` and `api` config too, anywhere a `where` appears.

| Operator | Matches |
|---|---|
| `and:` / `or:` | logical grouping — a **list** of sub-clauses, one clause per item |
| `=` `<` `>` `<=` `>=` | comparison, numeric or date |
| `like:` / `ilike:` | wildcard on one named field, `%` as the placeholder; `ilike` is case-insensitive |
| `in:` | value present in an array |
| `intree:` | descendant of a node, for pages held in a navigation tree |
| `?&` / `?\|` | multi-value: ALL of / ANY of |
| `search:` | full text across the fields declared `searchable: true` on the type |
| `&` prefix | **strict mode** on any of the above — see below |

### Empty variables are dropped, unless the clause is strict

By default a clause whose variable resolves to empty is **removed from the query**, and the remaining clauses run without it. A `where` reduced to nothing returns the entire collection. That is deliberate and often useful — an absent `?q=` on a search page lists everything rather than nothing — but it is also the single most expensive default here, because the failure is silent and looks like a template bug.

Prefix the operator with `&` to keep the clause even when its variable is empty, so a missing value yields **zero rows** instead of all of them:

```yaml
where:
  '&=':
    slug: '{{route.article.slug}}'
    active: active
```

Use strict mode for anything a detail page depends on (`slug`), and for filters that must always apply (`active`, a published flag). Use the default mode when an absent value legitimately means "no filter".

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>A mistyped variable is indistinguishable from an empty one.</b> Both get the clause dropped, both return more rows than intended, and neither raises. A listing rendering the whole collection where it should render six rows is the signature of a typo in a <code>{{…}}</code> token, not of a broken loop. The known trap is <code>{{current.&lt;field&gt;}}</code>, which does not exist — <code>current</code> exposes only <code>page</code> and <code>locale</code>, and a block's own fields live under <code>{{field.&lt;name&gt;}}</code>.</div></div>

### `searchable: true`

A per-field boolean on the type. It has one effect: it puts the field within reach of the `search:` operator. It is not required for `like`/`ilike`, which name their field directly, and it does not affect the back-office `filters:` list.

`search` is **word-based**: case- and accent-insensitive, several terms combined with AND, and no partial-word matching — a query on `forê` does not find *forêt*. It also reads the **translated values of the locale being rendered**, so on a bilingual site each language searches its own text. For substring behaviour on one named column, use `ilike` with `%` wildcards instead. All measured on the Lisière demo; see [Build a blog](/en/docs/blog), section 8.


## Validators

Rules listed inside `validate.config.rules.<field>: [rule…]`. A rule is either a bare keyword or a `{rule: {param}}` map.

| Rule | Param | Behaviour |
|---|---|---|
| `required` | — | Value must be non-empty. |
| `email` | — | Must be a valid email format. |
| `recaptcha` | — | Checks `g-recaptcha-response`. |
| `equals` | `value: '{{field.code}}'` | Exact match against another field. |
| `file-extensions` | `allowed: 'jpg,png'` | Comma-separated allow-list. |
| `file-size` | `max: 2097152` | Maximum size in bytes. |

```yaml
validation:
  type: validate
  config:
    rules:
      email: [email, required]
      firstName: []          # no rule, but declared — see below
      consent: [required]
```

<div class="q-callout q-callout-warning"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b><code>rules</code> is the allow-list of persisted fields, not just a validator.</b> A field the form posts but <code>rules</code> does not name is dropped on save, with no error and no trace: the submission still redirects, the collection entry simply comes back without it. Declaring it with an empty array — <code>firstName: []</code> — persists it without constraining it. Check every <code>name="…"</code> in the form template against this map; the mismatch is silent in both directions.</div></div>

## Spam filtering (`antispam`)

Beyond the `recaptcha` rule, a `validate` field can run the submission through the platform's spam filter — an IP / email / content reputation check. It is opt-in per form, and as with the captcha there is **no key to obtain and no account to open**: the platform holds the credentials.

```yaml
validation:
  type: validate
  config:
    rules:
      email: [email, required]
      g-recaptcha-response: [recaptcha]
    antispam:
      email: '{{body.email}}'
      message: 'Contact {{body.name}}: {{body.message}}'
```

`email` and `message` are interpolated from the POST (`{{body.<field>}}`) and they are **all the filter sees**: a field the template does not name is not checked. Keep the template in sync with the form — a `message` referencing fields that no longer exist interpolates to an empty string, and a filter that receives nothing waves everything through.

`silent` defaults to **`true`**: a submission flagged as spam still validates and redirects — the visitor sees a normal success — but every action behind the gate (`sendmail`, `save`, `http`) is skipped. Set `silent: false` to reject the submission openly, like any failed rule.

### When the mail actually leaves

`sendmail` applies its own gate on top of validation, and it fails **silently** — the visitor lands on the thank-you page either way:

- **Platform sender** (no custom `smtp`): the mail leaves only if a `recaptcha` rule validated on this submission. No captcha rule, no notification.
- **Valid custom `smtp`** (host, username, password, from all set): the captcha requirement is lifted.
- **The spam filter said spam**: nothing is sent, saved or forwarded, whatever the SMTP situation.
- **Interpolated recipient** (`to` containing `{{…}}`): allowed only when the spam filter actually checked the submission. A dynamic recipient with no spam check is skipped — that combination is what turns a contact form into an open relay.

`save` and `http` only apply the spam gate: they run with or without a captcha.

*Engine-sourced: read from the rendering engine's send path, not exercised live.*

## Image, date and wysiwyg config

### Image versions

An `image` field defines one or more named crops under `config.versions.<key>`. At least one is required. Templates read `field[0].versions.<key>.url`.

| Version key | Behaviour |
|---|---|
| `width` + `height` | Crop to that exact box. |
| `width` only / `height` only | Proportional resize on that axis. |
| `resolutions: {key: ratio}` | Generates a `srcset` from the named ratios. |

```yaml
image:
  type: image
  label: Image
  config:
    multiple: true   # gallery
    max: 12
    versions:
      card:
        label: Card
        width: 800
        height: 600
      cover:
        label: Cover
        width: 1600
        height: 900
```

`multiple: true` + `max: N` turns the field into a gallery (live-confirmed). **Write `[{id:'&lt;media-uuid&gt;'}]`.**

### Date and calendar

- `type: date` is a single day, `YYYY-MM-DD`, with `default: now`. Render with `value | date: 'DD MMMM YYYY'`.
- `type: calendar` holds an RRULE value object (`start`, `end`, `freq`, `interval`, `byday[]`, `count`) for recurring events. The template reads `_occurrence.start` / `_occurrence.end`, the single materialised occurrence. `translatable` defaults to **false**. *Skill-sourced, not live-confirmed.*

### Wysiwyg

A `wysiwyg` field is configured minimally per-field: only `config.limiter` (an integer character cap) beyond the common options. All visual styling comes from the site-wide [`miscellaneous.wysiwyg.styles`](/en/docs/site-settings#wysiwyg-styles) palette, not per-field config. The field renders as HTML.

## Related

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/content-types"><span class="k">Recommended</span><span class="t">Content-types</span><span class="d">Where fields are declared on a block.</span></a>
  <a class="q-card" href="/en/docs/itemset-types"><span class="t">collection-types</span><span class="d">Fields, displays and filters on a collection.</span></a>
  <a class="q-card" href="/en/docs/template-variables"><span class="t">Template variables</span><span class="d">How field values surface in Liquid.</span></a>
  <a class="q-card" href="/en/docs/site-settings"><span class="t">Site settings</span><span class="d">The site-wide wysiwyg styles palette.</span></a>
</div>
