Kwisple
Browse the docs

Content model

Blocks and relations

Matrix blocks (the page builder), relations to entries and assets, and link fields: how to declare them, what the SDK returns and how the dashboard presents them.

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: false on blocks: a block has no title of its own. Give it a titleFormat ("{heading}", "{question}") so collapsed blocks show something useful; without it the editor shows the first non-empty text field.
  • description and icon: 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 builder field, 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), with view: "cards". Nesting is not limited to one level; collectEntryTypes walks 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

OptionTypeDefaultMeaning
entryTypesEntryTypeDef[]requiredAllowed block types. Order = order in the Blok toevoegen menu.
min / maxnumberrequired ? 1 : 0 / unlimitedBlock 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 })
OptionTypeDefaultMeaning
sectionsstring[]requiredSections the picker searches. Every handle must exist in the schema (veld "related" verwijst naar onbekende sectie "blog".).
min / maxnumberrequired ? 1 : 0 / unlimitedCount 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 })
OptionTypeDefaultMeaning
kinds("image" | "svg" | "video" | "document")[]all kindsFilters the picker and the upload. Fonts in Media count as document.
min / maxnumberrequired ? 1 : 0 / unlimitedCount 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.

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 })
OptionTypeDefaultMeaning
typesLinkType[]["url", "entry"]Allowed kinds, in the order of the tabs the editor shows.
sectionsstring[]all sectionsSections the entry picker searches for entry links; validated against the schema.
withTextbooleantrueShow Linktekst.
translation"site" | "none""site"Per locale by default (the text is language-specific).
typeEditor labelStored valueResolved hrefDefault text
urlWebadresthe URL as typed (https://… or /pad)the same value""
entryPaginaentry idthe entry's public path with locale prefix (/over-ons, /fr/a-propos)the entry title
assetBestandasset idthe file URLthe file name
emailE-mailthe addressmailto:<address>the address
phoneTelefoonthe number as typedtel: + the number with everything but + and digits removedthe 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:

  • relatedTo queries;
  • 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, description and icon; one shared builder; a renderer per block with an exhaustive switch.
  • Relations: handle an empty array (the target may be offline) and query the targets when you need more than a title and URI.
  • Assets: kinds and max: 1 for 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, use href and text as given.

Last updated 2026-10-30