Three kinds of fields point beyond a plain value: f.matrix holds nested entries (blocks),
f.entries and f.assets reference other content, and f.link is a single target of several
possible kinds. This page covers how they are declared, stored, resolved and shown.
Blocks (f.matrix)
A matrix field is a list of blocks. Each block is an entry type (see defineEntryType), reused the way Craft 5 reuses entry types inside Matrix fields. The page builder of a site is simply a matrix field whose entry types are the blocks the design supports.
The starters' pattern: cms/blocks.ts and the builder
Both starters keep blocks in cms/blocks.ts:
import { defineEntryType, f } from "@kwisple/sdk";
import { common } from "./fields";
export const blockText = defineEntryType({
handle: "blockText",
name: "Tekst",
icon: "type",
description: "Titel met lopende tekst.",
title: false,
fields: { heading: common.heading, text: common.text.with({ required: true }) },
});
const card = defineEntryType({
handle: "card",
name: "Kaart",
icon: "square",
title: false,
fields: {
heading: common.heading.with({ required: true }),
text: f.plainText({ label: "Tekst", multiline: true, charLimit: 240 }),
image: common.image,
link: common.link,
},
});
export const blockCards = defineEntryType({
handle: "blockCards",
name: "Kaarten",
icon: "layout-grid",
description: "Raster van kaarten met link.",
title: false,
fields: {
heading: common.heading,
cards: f.matrix({ label: "Kaarten", entryTypes: [card], min: 1, max: 12, view: "cards" }),
},
});
/** The page builder field, shared by home and pages. */
export const builder = f.matrix({
label: "Inhoud",
instructions: "Bouw de pagina op met blokken.",
entryTypes: [blockText, blockMedia, blockSplit, blockCards, blockCta, blockFaq],
});Conventions worth keeping:
title: falseon blocks: a block has no title of its own. Give it atitleFormat("{heading}","{question}") so collapsed blocks show something useful; without it the editor shows the first non-empty text field.descriptionandicon: they make the Blok toevoegen menu (add block) self-explanatory; the menu is searchable on name and description and shows a layout diagram per block.- One shared
builderfield, used by every entry type that has a page body (home, page). Sharing the object means one definition to maintain and one block list for editors. - Nested matrix fields for repeaters inside a block (
cards,items,buttons), withview: "cards". Nesting is not limited to one level;collectEntryTypeswalks the whole tree.
The rendering side mirrors the model. In Next.js (src/builder/builder.tsx):
import { blockAttrs } from "@kwisple/sdk";
import type { BuilderBlock } from "@/lib/cms";
export function Builder({ blocks }: { blocks: BuilderBlock[] }) {
return (
<div className="flex flex-col">
{blocks.map((block, i) => (
<div key={block.id} {...blockAttrs(blockName(block.type), i)}>
<BuilderBlockView block={block} />
</div>
))}
</div>
);
}
function BuilderBlockView({ block }: { block: BuilderBlock }) {
switch (block.type) {
case "blockText":
return <TextBlock block={block} />;
case "blockMedia":
return <MediaBlock block={block} />;
// … one case per block type
default:
return assertNever(block);
}
}BuilderBlock is PageEntry["builder"][number], a union discriminated by type; the switch
narrows block so each component gets exactly its own fields, and a block added to
cms/blocks.ts without a case is a type error. blockAttrs(type, index) from
@kwisple/sdk adds data-kwisple-block="<position>:<type>" so the platform's statistics and
the Websitecheck can tell which blocks visitors saw and clicked. The Astro starter does the same
in src/builder.
To add a block: define the entry type in cms/blocks.ts, add it to builder, let
kwisple dev push it (the block appears in the editor at once), then write the component and
its case.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
entryTypes | EntryTypeDef[] | required | Allowed block types. Order = order in the Blok toevoegen menu. |
min / max | number | required ? 1 : 0 / unlimited | Block count limits, checked on publish (Inhoud: minstens 1 blokken., Knoppen: maximaal 2 blokken.). max also hides the add button once reached. |
view | "blocks" | "cards" | "blocks" | blocks: the page builder (one-line rows with a layout diagram, one block open at a time). cards: compact cards for small repeaters. |
translation | "site" | "none" | "site" | Per locale (default) or one block list shared by all locales. |
Stored and resolved shape
Stored (StoredBlock), also the shape inside a site kit (where id and enabled may be left
out; they are added on seed):
{ id: string; type: string; enabled: boolean; title?: string; fields: Record<string, unknown> }Block ids are stable: reordering keeps them, duplicating an entry or a block gives new ones.
Use block.id as the React key.
Resolved (BlockOf<T>), returned by the delivery API:
{ id: string; type: T["handle"]; title: string } & { /* field values flattened */ }Disabled blocks are not returned: a block hidden with Verbergen stays in the content but never reaches the site, and only gets the type check, not the strict publish validation. A block whose type no longer exists in the model is skipped on delivery and shown in the editor as Dit soort blok bestaat niet meer. De inhoud blijft bewaard. (this kind of block no longer exists; the content is kept). It comes back when the type returns.
Relations inside blocks (assets, entries, links) are resolved in the same batch as the entry's own fields, so a page with twenty image blocks costs one asset lookup.
What the editor shows
With view: "blocks" the field is the page builder: each block is a row with its icon, a
layout diagram and a one-line summary (titleFormat, else the first text), opened one at a
time like an accordion. The row menu has Omhoog / Omlaag (up/down, also drag to move:
Sleep om te verplaatsen), Dupliceren, Verbergen / Tonen (hide/show; hidden blocks
are marked verborgen) and Verwijderen. Below the list, Blok toevoegen opens the
searchable menu (Zoek een blok…).
With view: "cards" the same operations exist in a compact form. A block whose fields have
errors is opened automatically when the editor jumps to the first error.
Rights: people with Bewerken (edit) on website content change what is inside blocks; adding, reordering, hiding or removing blocks needs Beheren (manage). For those editors the field's instructions are replaced by Klik een blok open om de tekst of beelden aan te passen.
Relations (f.entries)
related: f.entries({ label: "Gerelateerd", sections: ["news"], max: 3 })
venue: f.entries({ label: "Locatie", sections: ["venues"], max: 1, required: true })| Option | Type | Default | Meaning |
|---|---|---|---|
sections | string[] | required | Sections the picker searches. Every handle must exist in the schema (veld "related" verwijst naar onbekende sectie "blog".). |
min / max | number | required ? 1 : 0 / unlimited | Count limits on publish (Gerelateerd: minstens 1., Gerelateerd: maximaal 3.). |
translation | "site" | "none" | "none" | Relations are shared across locales by default. |
Stored: an array of entry ids, in the chosen order.
Resolved: EntryRef[]:
interface EntryRef {
id: string;
section: string;
type: string;
title: string;
slug: string;
uri: string | null; // no locale prefix; use uriToPath() or build the URL yourself
}Only entries that are live in the requested locale are included (the lookup runs with the
same status rules as a normal read), so a related entry that is offline, scheduled or expired
disappears from the array without an error. A ref carries the summary fields, not the content:
to show a card with an image, query the entries themselves
(client.entries({ section: "news", id: entry.related.map((r) => r.id) })) or use
select to keep it small.
Queries. A relation field is filterable: where: { venue: venueId } means "contains". The
reverse direction uses the relation index: client.entries({ relatedTo: venue.id }) returns
every entry that references the venue through any entries, assets or link field. The index is
rebuilt on every publish (cms_relation) and also drives cache purges: publishing an entry
purges the pages that reference it.
Editor. The field shows the chosen entries as rows with an Online / Offline dot, Omhoog / Omlaag and a remove button, and a button that opens the picker (Kies een pagina of bericht, search Zoeken op titel…, {n} kiezen to confirm). The picker searches the allowed sections in the current language.
Assets (f.assets)
image: f.assets({ label: "Afbeelding", kinds: ["image"], max: 1 })
gallery: f.assets({ label: "Galerij", kinds: ["image", "svg"], min: 1, max: 24, required: true })
brochure: f.assets({ label: "Brochure", kinds: ["document"], max: 1 })| Option | Type | Default | Meaning |
|---|---|---|---|
kinds | ("image" | "svg" | "video" | "document")[] | all kinds | Filters the picker and the upload. Fonts in Media count as document. |
min / max | number | required ? 1 : 0 / unlimited | Count limits on publish (Galerij: minstens 1., Galerij: maximaal 24.). The editor shows the limits under the field when max > 1. |
translation | "site" | "none" | "none" | Shared by default; set "site" for images with text on them. |
Stored: { id: string; alt?: string }[]. The optional alt is a per-usage override of the
asset's own description.
Resolved: Asset[] (shape in the Fields reference). alt is the
override when one is set, else the asset's description. url is the public storage URL,
focalPoint is { x, y } in 0..1 from the top-left or null for the centre, placeholder a
tiny base64 image. Deleted assets are left out.
Render images with the SDK helpers so focal point, blur placeholder and responsive sources are
handled: imageProps(asset, …) for next/image and <Image asset={…} /> in Astro (see
Rendering content).
Alt text. Every image needs a description before the page can go online; the editor
asks for it on the asset (Beschrijving van het beeld, placeholder
Wat staat erop? (nodig om online te zetten)), Media marks assets with
Beschrijving ontbreekt, and the assistant can propose descriptions that a person accepts.
Put the reason in the field's instructions so editors know why.
Editor. A single-file field (max: 1) shows Kies een bestand, a multi-file field
Kies bestanden, both with of sleep een bestand hierheen (drop zone) and upload
progress. Each chosen file shows a thumbnail, the alt input, Focuspunt (opens the focal
point picker: click the important part, Centreren to reset) and Weghalen (remove). The
picker modal (Bestanden kiezen) searches by file name and description and filters on
kinds; a double-click chooses immediately.
Links (f.link)
A link field holds one target. It replaces the pattern of a URL field next to a text field and a "new window" toggle.
link: f.link({
label: "Link",
types: ["url", "entry", "email", "phone", "asset"],
sections: ["pages", "news", "home"],
})
button: f.link({ label: "Knop", types: ["entry", "url"], withText: true, required: true })| Option | Type | Default | Meaning |
|---|---|---|---|
types | LinkType[] | ["url", "entry"] | Allowed kinds, in the order of the tabs the editor shows. |
sections | string[] | all sections | Sections the entry picker searches for entry links; validated against the schema. |
withText | boolean | true | Show Linktekst. |
translation | "site" | "none" | "site" | Per locale by default (the text is language-specific). |
Link kinds
type | Editor label | Stored value | Resolved href | Default text |
|---|---|---|---|---|
url | Webadres | the URL as typed (https://… or /pad) | the same value | "" |
entry | Pagina | entry id | the entry's public path with locale prefix (/over-ons, /fr/a-propos) | the entry title |
asset | Bestand | asset id | the file URL | the file name |
email | the address | mailto:<address> | the address | |
phone | Telefoon | the number as typed | tel: + the number with everything but + and digits removed | the number as typed |
Stored: { type, value, text?, newWindow? } or null. Resolved: Link | null
(shape in the Fields reference): href is ready to put in an anchor,
text falls back as in the table, entry and asset carry the resolved EntryRef / Asset
when the kind is entry or asset.
An entry link whose target is not live resolves to null; an asset link whose file was
deleted too. Treat null as "no link", also for required fields, and render external url
links with rel="noopener" when newWindow is set.
Publishing a required link without a target fails with Link: kies een doel.. Rich-text
links are different: they are sanitised inside the HTML (see
f.richText) and are not resolved against entries.
Editor. With more than one kind in types, the field shows a Soort link (kind of link)
switch with one tab per kind. url shows an input (https:// of /pad); entry and asset
show the chosen title or file name (Niets gekozen when empty) and a button to pick one;
email and phone show a plain input. Below: Linktekst (when withText), Openen in
nieuw venster (open in a new window) and Link wissen (clear). Switching the kind keeps the
text and clears the target.
The relation index and cache purges
Every publish rewrites the entry's rows in cms_relation: one row per referenced entry or
asset, per field path (builder.<blockId>.image) and per locale, plus the SEO share image.
The index serves two purposes:
relatedToqueries;- cache tags: when an entry is published, the platform purges its own tag, its section tag and the tags of every entry that references it, and sends those tags to the site's webhook (see Live preview and revalidation).
You never write to it; it follows the content.
Checklist
- Blocks:
title: false+titleFormat,descriptionandicon; one sharedbuilder; a renderer per block with an exhaustiveswitch. - Relations: handle an empty array (the target may be offline) and query the targets when you need more than a title and URI.
- Assets:
kindsandmax: 1for a single image; render with the SDK image helpers; tell editors why alt text is needed. - Links: list the kinds you can render, handle
null, usehrefandtextas given.