Fields are declared with the helpers on f from @kwisple/sdk (defined in
packages/cms/src/fields.ts). A field is a plain object; the same object can be used in
many entry types and global sets, and .with({ label, instructions, required, width })
returns a copy with layout-specific overrides.
import { f } from "@kwisple/sdk";
const heading = f.plainText({ label: "Titel", charLimit: 120 });
const heroHeading = heading.with({ label: "Hero-titel", required: true });This page lists the options of every helper, the stored value (what the database and a
site kit hold), the resolved value (what the delivery API and the SDK return, typed by
FieldValue), the editor widget and the validation rules. Relations (f.assets,
f.entries, f.link) and f.matrix have their own page,
Blocks and relations; their options are summarised here for
completeness.
Common options
Every helper accepts these (FieldCommon):
| Option | Type | Default | Meaning |
|---|---|---|---|
label | string | required | The label in the editor. Also used in validation messages (Titel is verplicht.). |
instructions | string | none | Grey help text under the label. Write it for the editor, in their language. |
required | boolean | false | Must have a value to publish. Drafts are never blocked; the editor marks the field with * and lists what is missing when someone clicks Zet online (Nog niet online: 2 dingen om aan te vullen.). Only checked in locales that are enabled. |
translation | "site" | "none" | see per kind | "site": one value per locale. "none": one value shared by all locales. Text-like kinds default to "site", the rest to "none". On a multilingual site the editor shows a globe (Per taal in te vullen) next to translatable fields. |
width | 25 | 50 | 75 | 100 | 100 | Width in the editor's four-column layout, in percent. |
renamedFrom | string | string[] | none | Earlier handle(s) of this field, so a push reports a rename and reads keep using the stored value. See Renaming a field. |
.with() can override label, instructions, required and width only. Changing the
translation of an existing field is a destructive change (see
Localisation and URIs).
Value types
FieldValue<F> (in packages/cms/src/types.ts) maps a field definition to the type the SDK
returns. Values are flattened onto the entry (entry.heading) or block (block.heading).
| Kind | Resolved value type | Empty value |
|---|---|---|
plainText, richText, email, url | string | "" |
number | number | null | null |
toggle | boolean | default ?? false |
select (single) | V | null (V = union of option values) | default ?? null |
select (multiple: true) | V[] | [] |
date | string | null (ISO 8601) | null |
color | string | null (#rrggbb) | null |
link | Link | null | null |
assets | Asset[] | [] |
entries | EntryRef[] | [] |
matrix | BlockOf<T>[] | [] |
table | TableRow[] | [] |
json | unknown | null |
The empty value is what emptyValue(field) gives a new entry, and what a read returns for a
field the stored content does not have yet (reads are normalised against the current schema,
so adding a field never breaks old entries).
Validation: drafts versus publishing
Every stored value is checked with a Zod schema built from the field definition
(storedFieldSchema in packages/cms/src/values.ts). There are two levels:
- type check (always, also for autosaved drafts): a value must have the right shape — a string, a number, an array of blocks, a hex colour, an ISO date;
- strict (publishing,
kwisple seed, saving a global):required,charLimit,min/max, allowed block types and allowed select values are enforced.
Validation messages use the field's label, in Dutch, and are shown next to the field. In a
multilingual site, translatable fields are checked per enabled locale; an error in another
locale is announced with Ook iets op te lossen in: followed by the language. Disabled matrix
blocks (hidden with Verbergen) only get the type check: they are never rendered.
Two rules the engine applies on top of the field definitions:
- Alt text is required to publish an image. Not as a field option: an asset's description
(Beschrijving van het beeld) lives on the asset or on the usage; the editor asks for it
(
Wat staat erop? (nodig om online te zetten)) and the page checks list missing descriptions before the page goes online. Give editors the instruction in the field (the standard schema: "Beschrijf elk beeld kort voor wie het niet kan zien; dat is nodig om online te zetten."). - Rich text is sanitised on publish (and when a global is saved), see
f.richText.
f.plainText
Single-line or multi-line plain text.
f.plainText({ label: "Intro", multiline: true, charLimit: 320 })| Option | Type | Default | Meaning |
|---|---|---|---|
multiline | boolean | false | Textarea instead of a one-line input. |
charLimit | number | none | Maximum length. The editor stops typing at the limit and shows a counter (118/120) once 80 % is reached. Publishing checks it: Intro: maximaal 320 tekens. |
placeholder | string | none | Placeholder in the input. |
Default translation: "site". Stored and returned as a string; required means non-empty
after trimming. Filterable and sortable in client.entries({ where, orderBy }). Its text is
part of the dashboard search index.
f.richText
Formatted text as an HTML string.
f.richText({ label: "Tekst", toolbar: "full" })| Option | Type | Default | Meaning |
|---|---|---|---|
toolbar | "simple" | "full" | "simple" | simple: Vet, Cursief, Opsomming, Genummerde lijst, Link, Opmaak wissen. full: also Gewone tekst, Tussentitel (h2), Kleine titel (h3) and Citaat (blockquote). |
Default translation: "site". Stored and returned as an HTML string; required means the
text without tags is non-empty.
Sanitising. On publish (and when a global is saved) every rich-text value, also inside
matrix blocks, goes through sanitizeHtml: an allowlist of p, h2, h3, h4, ul, ol,
li, strong, em, a, br, blockquote. Other tags and HTML comments are dropped (their
text stays), every opened tag is closed, and on a only href survives when it starts with
http(s):, mailto:, tel:, / or #; external http(s) links get
rel="noopener noreferrer" target="_blank". Render the string as HTML without further escaping
(the starters have a RichText component). Tables are not part of the allowlist: for tabular
content use f.table, or alternate text and table blocks in a matrix as the RANSON.N site does.
htmlToText(html, max) from @kwisple/cms gives a plain-text excerpt for meta descriptions
and JSON-LD.
f.number
f.number({ label: "Prijs vanaf (€)", min: 0, decimals: 2, width: 50 })| Option | Type | Default | Meaning |
|---|---|---|---|
min | number | none | Lower bound, enforced on publish and as the input's min. |
max | number | none | Upper bound. |
decimals | number | 0 | Step of the input (1 / 10^decimals). |
suffix | string | none | Text shown after the input (€, m²). |
Default translation: "none". Stored and returned as number | null. Filterable with
gt/gte/lt/lte and sortable (orderBy: "price asc").
f.toggle
f.toggle({ label: "Verplicht", width: 50 })| Option | Type | Default | Meaning |
|---|---|---|---|
default | boolean | false | Value of a new entry. |
Default translation: "none". A switch in the editor; stored and returned as boolean.
Filterable (where: { featured: true }).
f.select
One or several values from a fixed list.
f.select({
label: "Achtergrond",
options: [
{ value: "default", label: "Standaard" },
{ value: "muted", label: "Zacht" },
{ value: "brand", label: "Merkkleur" },
],
default: "default",
display: "buttons",
translation: "none",
})| Option | Type | Default | Meaning |
|---|---|---|---|
options | { value: string; label: string }[] | required, at least one | The values (stored) and their labels (shown). Use as const when you share the list so the value union is preserved. |
multiple | boolean | false | Several values; stored as an array. |
display | "dropdown" | "radio" | "buttons" | "checkboxes" | see below | The widget. |
default | V | none | Value of a new entry (single select). |
Default translation: "none". Without display, a multiple select shows checkboxes and a
single select shows buttons up to four options and a dropdown beyond. buttons is a
segmented control that turns into a dropdown when the options do not fit the column.
A non-required single select can be cleared (the dropdown offers —; clicking the active
button again deselects). Publishing checks that every value is one of the options.
Resolved value: V | null or V[]. Filterable: where: { tone: "dark" }; for a multiple
select a plain value means "contains".
f.date
f.date({ label: "Datum van het evenement", time: true })| Option | Type | Default | Meaning |
|---|---|---|---|
time | boolean | false | Date and time instead of a date only. |
Default translation: "none". Stored and returned as an ISO 8601 string with offset
(2026-11-03T09:00:00.000Z); a date without time is stored at T00:00:00.000Z. Publishing
requires a valid ISO datetime. Filterable (where: { date: { gte: new Date().toISOString() } })
and sortable. The editor offers Wissen (clear) next to a chosen date.
The publish date of the entry itself (postDate) is a system field in Pagina-instellingen,
not a f.date.
f.color
f.color({ label: "Accentkleur" }) // brand swatches
f.color({ label: "Achtergrond", palette: ["#ffffff", "#111111"], allowCustom: true })| Option | Type | Default | Meaning |
|---|---|---|---|
palette | "brand" | string[] | "brand" | "brand": swatches from the client's brand kit (Merk). A list of hex values: a fixed palette. |
allowCustom | boolean | true for a fixed list, false for "brand" | Also show a colour picker and a hex input (Eigen kleur). |
Default translation: "none". Stored and returned as #rrggbb (string | null); publishing
requires a six-digit hex. With the brand palette and no brand colours yet, the editor says
Nog geen merkkleuren in je huisstijl. Keeping palette: "brand" keeps a site on the brand
kit, whose colours have passed the contrast checks of the brand editor.
f.link
One link to a URL, an entry, an asset, an e-mail address or a phone number, with optional text.
f.link({
label: "Link",
types: ["url", "entry", "email", "phone", "asset"],
sections: ["pages", "news", "home"],
})| Option | Type | Default | Meaning |
|---|---|---|---|
types | ("url" | "entry" | "asset" | "email" | "phone")[] | ["url", "entry"] | The kinds the editor offers (Webadres, Pagina, Bestand, E-mail, Telefoon). |
sections | string[] | all sections | Sections the entry picker searches when linking to an entry. Must exist in the schema. |
withText | boolean | true | Show the Linktekst input. |
Default translation: "site". Stored as { type, value, text?, newWindow? } where value is
the URL, entry id, asset id, address or number. Resolved to a Link:
interface Link {
type: "url" | "entry" | "asset" | "email" | "phone";
href: string; // ready to use: "/over-ons", "mailto:…", "tel:+32…", absolute URL
text: string; // the typed text, else the entry title / file name / address
newWindow: boolean;
entry?: EntryRef; // for type "entry"
asset?: Asset; // for type "asset"
}A link to an entry that is no longer live resolves to null, so always handle null.
Publishing requires a target when the field is required (Link: kies een doel.). Details and
the editor's behaviour are on Blocks and relations.
f.assets
Files from the client's Media.
f.assets({ label: "Afbeelding", kinds: ["image"], max: 1 })
f.assets({ label: "Galerij", kinds: ["image", "svg"], min: 1, max: 24, required: true })| Option | Type | Default | Meaning |
|---|---|---|---|
kinds | ("image" | "svg" | "video" | "document")[] | all | Which files the picker shows and accepts for upload. |
min | number | required ? 1 : 0 | Minimum number of files to publish. |
max | number | unlimited | Maximum; max: 1 gives a single-image widget. |
Default translation: "none". Stored as { id, alt? }[] (the alt overrides the asset's
own description for this usage); resolved to Asset[]:
interface Asset {
id: string;
url: string;
kind: "image" | "svg" | "video" | "document";
mimeType: string;
filename: string;
width: number | null;
height: number | null;
alt: string;
focalPoint: { x: number; y: number } | null; // 0..1 from the top-left
placeholder: string | null; // tiny base64 image for blur-up
}Assets that were deleted are left out of the array. Publishing checks min/max
(Galerij: minstens 1., Galerij: maximaal 24.). More on
Blocks and relations.
f.entries
References to other entries.
f.entries({ label: "Gerelateerd", sections: ["news"], max: 3 })| Option | Type | Default | Meaning |
|---|---|---|---|
sections | string[] | required | Sections the picker searches (Kies een pagina of bericht). Must exist. |
min | number | required ? 1 : 0 | Minimum to publish. |
max | number | unlimited | Maximum. |
Default translation: "none". Stored as an array of entry ids; resolved to EntryRef[]
({ id, section, type, title, slug, uri }) containing only entries that are live in the
requested locale. Filterable: where: { related: "<entry id>" }, and the reverse direction is
client.entries({ relatedTo: entry.id }). Details on
Blocks and relations.
f.matrix
Repeatable blocks, each an entry type.
f.matrix({ label: "Inhoud", instructions: "Bouw de pagina op met blokken.", entryTypes: [blockText, blockMedia] })
f.matrix({ label: "Knoppen", entryTypes: [button], max: 2, view: "cards" })| Option | Type | Default | Meaning |
|---|---|---|---|
entryTypes | EntryTypeDef[] | required, at least one | The block types the editor may add. |
min | number | required ? 1 : 0 | Minimum number of blocks to publish (Inhoud: minstens 1 blokken.). |
max | number | unlimited | Maximum (Knoppen: maximaal 2 blokken.). |
view | "blocks" | "cards" | "blocks" | blocks: the page builder, one open block at a time with layout diagrams. cards: compact cards for small repeaters (buttons, FAQ items, team members). |
Default translation: "site" (the whole block list is per locale). Stored as
{ id, type, enabled, title?, fields }[]; resolved to BlockOf<T>[] with the fields flattened
onto the block and disabled blocks removed. Everything about blocks is on
Blocks and relations.
f.table
Rows with fixed columns: opening hours, figures, social profiles.
f.table({
label: "Openingsuren",
columns: { day: { label: "Dag", type: "text" }, hours: { label: "Uren", type: "text" } },
max: 14,
})| Option | Type | Default | Meaning |
|---|---|---|---|
columns | Record<string, { label: string; type: "text" | "number" | "toggle" | "url" }> | required | Column handle → label and type. |
min | number | none | Minimum rows to publish. |
max | number | none | Maximum rows. |
Default translation: "site". Stored and returned as an array of row objects keyed by column
handle; TableRow types number columns as number | null, toggle as boolean, the rest as
string. The editor shows one calm row of inputs per item (no table header), Toevoegen /
Nog een toevoegen and a remove button per row.
Two conveniences for opening hours: a table whose columns are exactly day and hours is shown
as a week (seven rows with a Gesloten switch and two time inputs, exceptions as free rows
below), and a table with a text column day (or labelled Dag) offers
Vul de zeven weekdagen in when it is empty. The stored format stays plain text
("08:00–18:00", "Gesloten"), so render it as such.
f.email and f.url
f.email({ label: "E-mail", width: 50, translation: "none" })
f.url({ label: "Link naar de video", required: true, translation: "none" })No extra options. Default translation: "none". Stored and returned as a string ("" when
empty). Publishing requires a valid address (E-mail: ongeldig e-mailadres.) or an absolute
URL (Link naar de video: ongeldige URL.) when the value is not empty. Both are filterable.
f.json
f.json({ label: "Instellingen (JSON)" })No extra options. Default translation: "none". Stored and returned as-is (unknown); no
validation beyond being JSON. The editor shows a monospace textarea (Geavanceerd veld
(JSON)) that keeps the previous value until the text parses. Use it sparingly, for values a
developer sets up once; put it behind a tab such as "Geavanceerd".
Which fields can be queried
client.entries({ where, orderBy }) works on plainText, number, toggle, select,
date, color, email, url and entries fields of the queried sections. Filtering or
sorting on another kind is a 400: Op veld "body" (richText) kan je niet filteren of sorteren.
See Querying entries.
Example: a complete entry type
import { defineEntryType, f } from "@kwisple/sdk";
export const event = defineEntryType({
handle: "event",
name: "Evenement",
icon: "calendar",
fields: {
intro: f.plainText({ label: "Intro", multiline: true, charLimit: 320, required: true }),
date: f.date({ label: "Wanneer", time: true, required: true, width: 50 }),
price: f.number({ label: "Prijs", min: 0, decimals: 2, suffix: "€", width: 50 }),
soldOut: f.toggle({ label: "Uitverkocht", width: 50 }),
category: f.select({
label: "Categorie",
options: [
{ value: "music", label: "Muziek" },
{ value: "talk", label: "Lezing" },
{ value: "workshop", label: "Workshop" },
],
default: "music",
width: 50,
}),
image: f.assets({ label: "Beeld", kinds: ["image"], max: 1, required: true }),
body: f.richText({ label: "Beschrijving", toolbar: "full" }),
tickets: f.link({ label: "Tickets", types: ["url", "entry"], withText: true }),
venue: f.entries({ label: "Locatie", sections: ["venues"], max: 1 }),
},
tabs: [
{ name: "Inhoud", fields: ["intro", "image", "body"] },
{ name: "Praktisch", fields: ["date", "price", "soldOut", "category", "tickets", "venue"] },
],
});On the site, Entry<typeof schema, "events"> then has intro: string,
date: string | null, price: number | null, soldOut: boolean,
category: "music" | "talk" | "workshop" | null, image: Asset[], body: string,
tickets: Link | null and venue: EntryRef[], next to the system fields.