Kwisple
Browse the docs

Content model

Fields reference

Every field helper in f, with its options, the stored value, the value the SDK returns, how the editor shows it and what the engine validates on publish.

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):

OptionTypeDefaultMeaning
labelstringrequiredThe label in the editor. Also used in validation messages (Titel is verplicht.).
instructionsstringnoneGrey help text under the label. Write it for the editor, in their language.
requiredbooleanfalseMust 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.
width25 | 50 | 75 | 100100Width in the editor's four-column layout, in percent.
renamedFromstring | string[]noneEarlier 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).

KindResolved value typeEmpty value
plainText, richText, email, urlstring""
numbernumber | nullnull
togglebooleandefault ?? false
select (single)V | null (V = union of option values)default ?? null
select (multiple: true)V[][]
datestring | null (ISO 8601)null
colorstring | null (#rrggbb)null
linkLink | nullnull
assetsAsset[][]
entriesEntryRef[][]
matrixBlockOf<T>[][]
tableTableRow[][]
jsonunknownnull

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 })
OptionTypeDefaultMeaning
multilinebooleanfalseTextarea instead of a one-line input.
charLimitnumbernoneMaximum 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.
placeholderstringnonePlaceholder 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" })
OptionTypeDefaultMeaning
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 })
OptionTypeDefaultMeaning
minnumbernoneLower bound, enforced on publish and as the input's min.
maxnumbernoneUpper bound.
decimalsnumber0Step of the input (1 / 10^decimals).
suffixstringnoneText 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 })
OptionTypeDefaultMeaning
defaultbooleanfalseValue 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",
})
OptionTypeDefaultMeaning
options{ value: string; label: string }[]required, at least oneThe values (stored) and their labels (shown). Use as const when you share the list so the value union is preserved.
multiplebooleanfalseSeveral values; stored as an array.
display"dropdown" | "radio" | "buttons" | "checkboxes"see belowThe widget.
defaultVnoneValue 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 })
OptionTypeDefaultMeaning
timebooleanfalseDate 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 })
OptionTypeDefaultMeaning
palette"brand" | string[]"brand""brand": swatches from the client's brand kit (Merk). A list of hex values: a fixed palette.
allowCustombooleantrue 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.

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"],
})
OptionTypeDefaultMeaning
types("url" | "entry" | "asset" | "email" | "phone")[]["url", "entry"]The kinds the editor offers (Webadres, Pagina, Bestand, E-mail, Telefoon).
sectionsstring[]all sectionsSections the entry picker searches when linking to an entry. Must exist in the schema.
withTextbooleantrueShow 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 })
OptionTypeDefaultMeaning
kinds("image" | "svg" | "video" | "document")[]allWhich files the picker shows and accepts for upload.
minnumberrequired ? 1 : 0Minimum number of files to publish.
maxnumberunlimitedMaximum; 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 })
OptionTypeDefaultMeaning
sectionsstring[]requiredSections the picker searches (Kies een pagina of bericht). Must exist.
minnumberrequired ? 1 : 0Minimum to publish.
maxnumberunlimitedMaximum.

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" })
OptionTypeDefaultMeaning
entryTypesEntryTypeDef[]required, at least oneThe block types the editor may add.
minnumberrequired ? 1 : 0Minimum number of blocks to publish (Inhoud: minstens 1 blokken.).
maxnumberunlimitedMaximum (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,
})
OptionTypeDefaultMeaning
columnsRecord<string, { label: string; type: "text" | "number" | "toggle" | "url" }>requiredColumn handle → label and type.
minnumbernoneMinimum rows to publish.
maxnumbernoneMaximum 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.

Last updated 2026-10-30