Kwisple
Browse the docs

Content model

Schema as code

How a site's content model is declared in cms.config.ts with defineSchema, defineSection, defineEntryType, defineGlobalSet and defineForm, and how it becomes the editor in the dashboard.

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;
KeyTypeRequiredMeaning
sectionsSectionDef[]yesThe sections of the site, in the order the dashboard navigation shows them.
globalsGlobalSetDef[]noGlobal sets, shown under Info op elke pagina (information on every page).
formsFormDef[]noForms the site submits with client.submitForm; the platform checks submissions against them. See Forms, bookings and confirmations.
appsAppDef[]noCustom 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",
});
OptionTypeDefaultMeaning
handlestringrequiredIdentifier, 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.
namestringrequiredWhat editors see in the navigation and as the title of the list.
type"single" | "channel" | "structure"requiredSee the three kinds below.
entryTypesEntryTypeDef[]required, at least oneThe 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).
uristring | Record<string, string> | nullundefinedURI 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.
maxLevelsnumberunlimitedStructures 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.
seobooleantrue when entries have URLsShow the Zoekmachines (search engines) tab in the editor. Only meaningful for sections with URLs; seo: false hides it.
previewbooleantrue when entries have URLsShow the Voorbeeld (preview) button and the live preview pane.
iconstringicon of the first entry typeLucide 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" }),
  },
});
OptionTypeDefaultMeaning
handlestringrequiredcamelCase identifier. It is the type of every entry and block (entry.type === "teamMember"), the discriminator your views switch on.
namestringrequiredThe human name: in the entry list, the Soort (kind) chooser when a section allows several types, and the Blok toevoegen (add block) menu.
iconstringnoneLucide icon name, shown in the navigation, the page header and the block menu.
colorstringnoneHex colour for the entry type chip.
title{ label?: string; translation?: "site" | "none" } | falsetitle shown, label TitelThe 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.
titleFormatstringnoneFor blocks without a title: which field names the collapsed block, e.g. "{heading}", "{link.text}".
fieldsRecord<string, FieldDef>requiredThe 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 everythingGroups fields into tabs. Fields that are not listed land in the first tab. Every handle in a tab must exist in fields.
descriptionstringnoneOne 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 seo is not false): 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 as entry.seo: { title, description, image, noindex, canonical }, with seo.title falling 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, postDate in the future), Verlopen (expired), Offline (disabled). The delivery API only returns entries that are enabled, past their postDate and before their expiryDate.
  • 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" } },
    }),
  },
});
OptionTypeMeaning
handlestringcamelCase identifier; the key in client.globals().
namestringThe name under Info op elke pagina in the dashboard.
iconstringLucide icon name for the navigation.
fieldsRecord<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).
  • renamedFrom on 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 }) and f.link({ sections }) only name sections that exist (veld "related" verwijst naar onbekende sectie "blog".);
  • f.select has at least one option; f.matrix has at least one entry type;
  • every tab field exists (tab "Hero" verwijst naar onbekend veld "heroTitle".);
  • renamedFrom does 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 schemaIn the dashboard
sections order, name, iconThe site's navigation: singles by name, structures under their name, channels under Lijsten, globals under Info op elke pagina.
Section typeEén pagina (single), Lijst (channel) or Boom (structure) in the model overview under Instellingen → Ontwikkeling → Contentmodel.
Several entryTypes in a sectionNieuwe 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, …).
titleThe big title input at the top, with the custom label; hidden for title: false.
fields key order and widthCraft's four-column layout: 25, 50, 75, 100 percent; fields flow in key order.
tabsTabs above the fields; the Zoekmachines tab is added after yours when the section has URLs. A tab with errors shows the count.
label, instructions, requiredLabel, 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.
uriWebadres in page settings, previewed live from the format while you type the slug.
seo, previewThe Zoekmachines tab and the Voorbeeld button.
description and icon on block entry typesThe 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 in standard/blocks.ts (hero, richText, features, imageText, gallery, cta, faq, testimonials, logos, stats, team, contactForm, collectionList, embed), each with a renderer in apps/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:

TypeWhat it gives
SchemaDefThe 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.
EntrySystemThe 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, SeoThe 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

Last updated 2026-10-30