A site's content model is one TypeScript file in the site repository, cms.config.ts. It
declares the sections, entry types, fields, global sets, forms and apps of the site. The CLI
pushes it to the platform (kwisple dev while you work, kwisple push in CI, see
Workflow: dev, push and changes); the dashboard builds the editor from it; the
SDK infers the TypeScript types of every entry from it. There is no code generation step and
no project-config YAML.
The DSL lives in @kwisple/cms and is re-exported from @kwisple/sdk, so a site imports
everything from one place:
import { defineEntryType, defineGlobalSet, defineSchema, defineSection, f } from "@kwisple/sdk";The model borrows its concepts from Craft CMS: sections (single, channel, structure), reusable entry types, a field library, Matrix blocks and global sets. If you come from Craft, read Coming from Craft CMS alongside this page.
The shape of cms.config.ts
import { defineEntryType, defineGlobalSet, defineSchema, defineSection, f } from "@kwisple/sdk";
import { builder } from "./cms/blocks";
import { common } from "./cms/fields";
const home = defineEntryType({
handle: "home",
name: "Home",
icon: "home",
fields: {
heroHeading: common.heading.with({ label: "Hero-titel", required: true }),
heroIntro: common.intro,
heroImage: common.image,
heroLink: common.link,
builder,
},
tabs: [
{ name: "Hero", fields: ["heroHeading", "heroIntro", "heroImage", "heroLink"] },
{ name: "Inhoud", fields: ["builder"] },
],
});
const page = defineEntryType({
handle: "page",
name: "Pagina",
icon: "file-text",
fields: { intro: common.intro, heroImage: common.image, builder },
});
const newsArticle = defineEntryType({
handle: "newsArticle",
name: "Nieuwsbericht",
icon: "newspaper",
fields: {
intro: common.intro.with({ required: true }),
image: common.image,
body: common.text.with({ label: "Artikel", required: true }),
related: f.entries({ label: "Gerelateerd", sections: ["news"], max: 3 }),
},
});
export default defineSchema({
sections: [
defineSection({ handle: "home", name: "Home", type: "single", entryTypes: [home], uri: "__home__" }),
defineSection({
handle: "pages",
name: "Pagina's",
type: "structure",
entryTypes: [page],
uri: "{parent.uri}/{slug}",
maxLevels: 3,
}),
defineSection({
handle: "news",
name: "Nieuws",
type: "channel",
entryTypes: [newsArticle],
uri: "nieuws/{slug}",
defaultOrder: "postDate desc",
}),
],
globals: [
defineGlobalSet({
handle: "contact",
name: "Contactgegevens",
icon: "phone",
fields: {
phone: f.plainText({ label: "Telefoon", translation: "none" }),
email: f.email({ label: "E-mail", translation: "none" }),
},
}),
],
});This is the model of both starters (starters/next/cms.config.ts, starters/astro/cms.config.ts).
They split the reusable parts into cms/fields.ts (the field library) and cms/blocks.ts
(the page-builder blocks); kwisple dev watches that whole folder.
The CLI loads the file with tsx and takes export default (or a named export schema). It
must be a defineSchema({...}) result with a sections array, otherwise the CLI stops with
cms.config.ts moet een schema exporteren (export default defineSchema({...})).
defineSchema
function defineSchema(def: {
sections: readonly SectionDef[];
globals?: readonly GlobalSetDef[];
forms?: readonly FormDef[];
apps?: readonly AppDef[];
}): SchemaDef;| Key | Type | Required | Meaning |
|---|---|---|---|
sections | SectionDef[] | yes | The sections of the site, in the order the dashboard navigation shows them. |
globals | GlobalSetDef[] | no | Global sets, shown under Info op elke pagina (information on every page). |
forms | FormDef[] | no | Forms the site submits with client.submitForm; the platform checks submissions against them. See Forms, bookings and confirmations. |
apps | AppDef[] | no | Custom tools of this site in the client's dashboard. See Declaring an app. |
defineSchema validates the whole model and throws a SchemaError listing every problem
when something is wrong (the problems are Dutch sentences, the same ones the CLI prints under
Contentmodel klopt niet:). The checks are listed under Validation below.
Because defineSchema uses const type parameters, the literal handles, option values and
field kinds survive into the type, which is what makes Entry<typeof schema, "news"> precise.
defineSection
A section groups entries that behave the same way: one fixed page, a flat list or a tree.
defineSection({
handle: "news",
name: "Nieuws",
type: "channel",
entryTypes: [newsArticle],
uri: "nieuws/{slug}",
defaultOrder: "postDate desc",
});| Option | Type | Default | Meaning |
|---|---|---|---|
handle | string | required | Identifier, camelCase (/^[a-z][a-zA-Z0-9]*$/). Used in queries (client.entries({ section: "news" })), in f.entries({ sections }) and f.link({ sections }), and in the URL of the dashboard. |
name | string | required | What editors see in the navigation and as the title of the list. |
type | "single" | "channel" | "structure" | required | See the three kinds below. |
entryTypes | EntryTypeDef[] | required, at least one | The entry types allowed in this section. A single may have exactly one. The first one is the default when an entry is created (and the one kwisple seed uses when a kit line has no type). |
uri | string | Record<string, string> | null | undefined | URI format of the entries. undefined or null means the entries have no URL (a list of team members, testimonials). A string is one format for every locale; an object gives a format per locale. See Localisation and URIs. |
maxLevels | number | unlimited | Structures only: how deep the tree may go (1 = flat). Enforced when an entry is created or moved: Maximaal 3 niveaus in "Pagina's". |
defaultOrder | "postDate desc" | "postDate asc" | "title asc" | "postDate desc" | Channels: the order the dashboard list and the delivery API use when no orderBy is given. |
seo | boolean | true when entries have URLs | Show the Zoekmachines (search engines) tab in the editor. Only meaningful for sections with URLs; seo: false hides it. |
preview | boolean | true when entries have URLs | Show the Voorbeeld (preview) button and the live preview pane. |
icon | string | icon of the first entry type | Lucide icon name for the section in the dashboard navigation, e.g. "house", "newspaper". |
Section kinds
single — exactly one entry (home, contact, privacy statement). The platform creates
the entry itself after every production push (ensureSingles), with the section's name as
its first title. The uri must be a fixed string without tokens; "__home__" is the homepage
(/). A single with a token in its URI is a validation error:
Single "home" heeft een vaste URI nodig, geen tokens. A single may have only one entry type.
In the dashboard a single appears as its own item (the standard schema calls it
Homepagina).
channel — a flat stream (blog, news, projects, services). Entries are ordered by
defaultOrder; the dashboard shows them as a searchable list with a status filter
(Zoeken…, Status) under Lijsten (lists). Channels usually have a URI with {slug},
optionally with {postDate.year} and {postDate.month}.
structure — an ordered tree (pages). Entries have a parent, a level and a sort order;
editors drag them in the tree (Pagina's), use Subpagina toevoegen (add sub-page),
Onder de vorige pagina zetten (nest under the previous page) and Een niveau hoger zetten
(move one level up), or set Valt onder (belongs under) in Pagina-instellingen (page
settings). The URI format normally contains {parent.uri}, so moving a page changes the
URIs of the whole subtree and creates redirects. client.tree(section) returns the tree for
menus.
defineEntryType
An entry type is the field layout of an entry (or of a block inside a matrix field). It is a plain object; define it once and reuse the same object in several sections and matrix fields, exactly like Craft 5 entry types.
const teamMember = defineEntryType({
handle: "teamMember",
name: "Teamlid",
icon: "user",
title: { label: "Naam" },
fields: {
role: f.plainText({ label: "Functie", charLimit: 120, width: 50 }),
photo: image.with({ label: "Foto" }),
email: f.email({ label: "E-mail", width: 50, translation: "none" }),
},
});| Option | Type | Default | Meaning |
|---|---|---|---|
handle | string | required | camelCase identifier. It is the type of every entry and block (entry.type === "teamMember"), the discriminator your views switch on. |
name | string | required | The human name: in the entry list, the Soort (kind) chooser when a section allows several types, and the Blok toevoegen (add block) menu. |
icon | string | none | Lucide icon name, shown in the navigation, the page header and the block menu. |
color | string | none | Hex colour for the entry type chip. |
title | { label?: string; translation?: "site" | "none" } | false | title shown, label Titel | The entry's title field. label renames it ({ label: "Naam" } for a team member). false hides the title input; the editor then shows the entry type's name and uses titleFormat for block summaries. Use false for matrix blocks. |
titleFormat | string | none | For blocks without a title: which field names the collapsed block, e.g. "{heading}", "{link.text}". |
fields | Record<string, FieldDef> | required | The fields by handle. The key order is the order in the editor (the standard schema's comment: "what people fill in first comes first"). |
tabs | { name: string; fields: string[] }[] | one tab with everything | Groups fields into tabs. Fields that are not listed land in the first tab. Every handle in a tab must exist in fields. |
description | string | none | One line under the name in the Blok toevoegen menu, which is searchable on name and description. |
The title is a system field: it is required to publish (Titel is verplicht., or the
custom label) in every enabled locale, unless title: false. Translatable by default; set
title: { translation: "none" } to share one title across locales.
Reserved handles
A field may not use one of the handles the system puts on every entry: id, type,
section, title, slug, uri, url, locale, status, enabled, postDate,
expiryDate, parentId, level, children, seo, createdAt, updatedAt, author
(reservedHandles in @kwisple/cms). The validator reports
Entry type "page": "slug" is een gereserveerde naam.
Fields and the field library
Fields are built with the f helpers (f.plainText, f.richText, f.assets, …), described
in the Fields reference. Each helper returns a plain, JSON-serialisable object
plus a non-enumerable .with() method that returns a copy with per-layout overrides for
label, instructions, required and width:
export const common = {
heading: f.plainText({ label: "Titel", charLimit: 120 }),
image: f.assets({ label: "Afbeelding", kinds: ["image"], max: 1 }),
};
// Same field, other layout: label and required differ, everything else is shared.
heroHeading: common.heading.with({ label: "Hero-titel", required: true }),Define the library in cms/fields.ts and reuse it: a push reports a field as the same field
as long as its kind is the same, so sharing objects keeps the model consistent and the diff
small.
SEO, URL and scheduling: what the editor adds for you
You do not declare these; every entry in a section with URLs gets them:
- Zoekmachines tab (when
seois notfalse): Titel in Google, Omschrijving in Google, Beeld bij het delen (share image), and under advanced options Oorspronkelijk adres (canonical) and Verbergen voor zoekmachines (noindex). Delivered asentry.seo: { title, description, image, noindex, canonical }, withseo.titlefalling back to the entry title. - Pagina-instellingen (page settings): Webadres with Laatste deel van het webadres
(the slug; empty = derived from the title), Valt onder for structures, Online vanaf
(
postDate) and Offline vanaf (expiryDate), Verbergen van de website (per locale when the site has several languages: Verbergen in het Frans), Soort when the section allows several entry types, Dupliceren and Verwijderen. - Statuses: Concept (never published), Online, Gepland (scheduled,
postDatein the future), Verlopen (expired), Offline (disabled). The delivery API only returns entries that are enabled, past theirpostDateand before theirexpiryDate. - Vorige versies (revisions): every publish stores a snapshot; Terugzetten loads one into the editor's draft.
The expiry date must be after the publish date (De vervaldatum moet na de publicatiedatum liggen.).
defineGlobalSet
A global set is a group of fields that exists once per site: contact details, footer text, social profiles.
defineGlobalSet({
handle: "footer",
name: "Footer",
icon: "panel-bottom",
fields: {
text: f.plainText({ label: "Korte tekst", multiline: true }),
links: f.matrix({ label: "Links", entryTypes: [footerLink], view: "cards" }),
socials: f.table({
label: "Sociale media",
translation: "none",
columns: { label: { label: "Naam", type: "text" }, url: { label: "URL", type: "url" } },
}),
},
});| Option | Type | Meaning |
|---|---|---|
handle | string | camelCase identifier; the key in client.globals(). |
name | string | The name under Info op elke pagina in the dashboard. |
icon | string | Lucide icon name for the navigation. |
fields | Record<string, FieldDef> | Fields, same rules as entry types (reserved handles included). Matrix fields are allowed. |
Globals have no drafts or revisions: Opslaan en online zetten (save and put online)
validates the values strictly (required fields, limits), sanitises rich text and stores them at
once. Values of fields with translation: "none" are stored once (locale *), the others per
locale. The site reads them with client.globals(locale), which returns
{ contact: {...}, footer: {...} } typed as Globals<typeof schema>.
defineForm
Forms declare what a form on the site sends, so the platform can check submissions, the
dashboard's form inbox (Formulieren) shows proper labels and client.submitForm is typed.
They are part of the schema (forms: [contact]) and pushed with it; changes to forms are
never destructive. The field kinds, validation and the submission API are documented in
Forms, bookings and confirmations.
const contact = defineForm({
handle: "contact",
name: "Contactformulier",
fields: {
name: { kind: "text", label: "Naam", required: true },
email: { kind: "email", label: "E-mail", required: true },
newsletter: { kind: "checkbox", label: "Nieuwsbrief" },
},
});
export default defineSchema({ sections: [/* … */], forms: [contact] });Form handles follow a looser rule than content handles: letters, digits, - and _
(/^[a-zA-Z][a-zA-Z0-9_-]*$/). Forms that are not declared keep working, untyped.
Apps in the schema
apps: [defineApp({...})] declares custom tools of this site in the client's dashboard. The
schema validator checks their handle (/^[a-z][a-z0-9-]{0,39}$/), name, path, area and
level. Everything else about apps is on Apps overview.
Handles and naming rules
- Section, entry type, global set and field handles are camelCase: a lowercase letter followed
by letters and digits (
/^[a-z][a-zA-Z0-9]*$/).Sectie "blog-posts": handle moet camelCase zijn. - Handles are unique within their kind: two sections or two globals with the same handle are an
error (
Sectie "news" bestaat dubbel.). - The same entry type handle must mean the same definition everywhere. Reuse the object; two
different definitions under one handle give
Entry type "card" is twee keer verschillend gedefinieerd.(this is checked on the JSON form, so an entry type nested in two matrix fields must be the same object or an identical copy). - Field handles are the keys of
fields; they may not be reserved handles (list above). renamedFromon a field must not name a field that still exists in the same layout:veld "lead" heet ook "intro" (renamedFrom), maar dat veld bestaat nog. Verwijder het oude veld.See Renaming a field.- Names (
name,label,instructions, option labels) are free text in the language of the editors. Code-defined models are shown exactly as written; only the built-in standard schema is translated by the dashboard.
Validation
validateSchema(schema) returns the list of problems that defineSchema throws, that the
CLI prints before a push and that the platform re-checks on POST /api/v1/schema (a model
with problems is answered with HTTP 422 and problems, and nothing is stored). It checks:
- handle format and uniqueness of sections, entry types, globals, forms and apps;
- a section has at least one entry type; a single has at most one and a fixed URI;
- every field handle is camelCase and not reserved;
f.entries({ sections })andf.link({ sections })only name sections that exist (veld "related" verwijst naar onbekende sectie "blog".);f.selecthas at least one option;f.matrixhas at least one entry type;- every tab field exists (
tab "Hero" verwijst naar onbekend veld "heroTitle".); renamedFromdoes not point at a live field;- forms have fields, known kinds and options for selects; apps have a valid handle, a name, a path on the site, a known area and level.
The error message of a failed defineSchema starts with Ongeldig contentmodel: and lists one
problem per line.
How the schema becomes the editor
The dashboard renders the editor from the stored model; nothing is configured by hand.
| In the schema | In the dashboard |
|---|---|
sections order, name, icon | The site's navigation: singles by name, structures under their name, channels under Lijsten, globals under Info op elke pagina. |
Section type | Eén pagina (single), Lijst (channel) or Boom (structure) in the model overview under Instellingen → Ontwikkeling → Contentmodel. |
Several entryTypes in a section | Nieuwe pagina asks for the kind; the editor shows Soort in page settings. The standard schema's list button is named per type (Nieuw bericht, Nieuw project, …). |
title | The big title input at the top, with the custom label; hidden for title: false. |
fields key order and width | Craft's four-column layout: 25, 50, 75, 100 percent; fields flow in key order. |
tabs | Tabs above the fields; the Zoekmachines tab is added after yours when the section has URLs. A tab with errors shows the count. |
label, instructions, required | Label, grey help text under it, a red asterisk. |
translation: "site" | A small globe icon (Per taal in te vullen, to fill in per language) on sites with more than one language, and a Taal switch at the top. |
uri | Webadres in page settings, previewed live from the format while you type the slug. |
seo, preview | The Zoekmachines tab and the Voorbeeld button. |
description and icon on block entry types | The searchable Blok toevoegen menu with a layout diagram per block. |
What each field kind looks like is in the Fields reference; the matrix and relation widgets are in Blocks and relations.
Who may do what in the editor is decided by the client's access policy, not by the schema: with Bewerken (edit) people change texts and images; adding, reordering or removing blocks needs Beheren (manage). The agency team is never limited.
The standard schema
Hosted sites (mode: "hosted", legacy block sites rendered by apps/web) have no repository
and therefore no cms.config.ts. They use the built-in standard schema from
@kwisple/cms/standard (packages/cms/src/standard), written with exactly the same DSL:
- sections Homepagina (single), Pagina's (structure,
{parent.uri}/{slug}, 3 levels), Nieuws & blog (blog/{slug}), Projecten (projecten/{slug}), Diensten (diensten/{slug}), Team and Getuigenissen (no URLs,seo: false,preview: false); - a page builder (
builder) with the blocks instandard/blocks.ts(hero,richText,features,imageText,gallery,cta,faq,testimonials,logos,stats,team,contactForm,collectionList,embed), each with a renderer inapps/web/src/blocks; - globals Contactgegevens and Sociale media.
It is pushed with source: "standard" (the agency clicks Standaard contentmodel toepassen
or Standaard contentmodel bijwerken under Instellingen → Ontwikkeling), and the dashboard
translates its labels for people who use the dashboard in another language
(localizeStandard). Code-defined models are never translated.
The standard schema is also the best worked example of the DSL: a shared field library
(standard/fields.ts), blocks with title: false and titleFormat, nested matrix fields
(buttons, items), f.table for opening hours and figures, and .with() overrides
everywhere. Its exported types StandardSchema, StandardBlock, StandardEntry,
StandardContact and StandardSocial show the inference helpers at work.
You cannot push a code model to a hosted site: POST /api/v1/schema answers 409
Deze site is een gehoste site met het standaardschema; schema's pushen kan alleen voor sites met een eigen repo.
Type inference
Everything the delivery API returns is typed from typeof schema. The helpers live in
@kwisple/cms and are re-exported from @kwisple/sdk:
| Type | What it gives |
|---|---|
SchemaDef | The type of a defineSchema result. |
SectionHandle<S> | Union of section handles: "home" | "pages" | "news". |
Entry<S, H> | Union of the entry shapes of section H (one per entry type). Entry<S> without H is every entry of the site. |
EntryOf<T> | The entry shape of one entry type T: EntrySystem & { type: T["handle"] } & field values. |
SectionEntry<Sec> | Same as Entry, from a SectionDef instead of a handle. |
EntrySystem | The system fields: id, section, title, slug, uri, url, locale, postDate, expiryDate, parentId, level, seo, updatedAt. |
BlockOf<T> | A matrix block of entry type T: { id, type, title } & field values. |
GlobalHandle<S>, Global<S, H> | The handles and the value object of a global set. |
FormHandle<S>, FormValues<S, H> | Form handles and the typed values submitForm accepts. |
Asset, EntryRef, Link, Seo | The resolved value shapes of assets, relations, links and the SEO tab. |
isType(e, handle) | Type guard that narrows an entry or block union by type. |
Field values are flattened onto the entry, Craft style (entry.heading, not
entry.fields.heading). The value type of each field kind is in the
Fields reference.
Both starters keep their derived types in src/lib/cms.ts:
import type { BlockOf, Entry } from "@kwisple/sdk";
import type schema from "../../cms.config";
export type Schema = typeof schema;
export type SiteEntry = Entry<Schema>; // any entry, discriminated by `type`
export type PageEntry = Entry<Schema, "pages">;
export type BuilderBlock = PageEntry["builder"][number]; // any block of the builder
export type BlockProps<T extends BuilderBlock["type"]> = {
block: Extract<BuilderBlock, { type: T }>;
};and switch exhaustively on entry.type and block.type; a block added to cms/blocks.ts
without a renderer is a type error. The client itself is typed by passing the schema type:
// Next.js
export const client = createClient<typeof schema>({ url: env("KWISPLE_URL"), token: env("KWISPLE_TOKEN") });
// Astro
export const client = createKwispleClient<typeof schema>({ schema });Passing the schema value as well (schema) switches on the model check that warns when the
platform serves a different model than your code; see
Workflow: dev, push and changes.
Where to go next
- Fields reference: every
fhelper, its options and values. - Blocks and relations: matrix blocks,
f.entries,f.assets,f.link. - Localisation and URIs: locales, translatable fields, URI formats.
- Workflow: dev, push and changes: getting the model onto the platform safely.