Your content model lives in cms.config.ts, but editors and the delivery API work with the copy
the platform holds. This page is about getting changes from one to the other: the development
model kwisple dev keeps in sync while you work, the live model kwisple push promotes, what
counts as a destructive change, how to rename a field without losing content, and how to run
all of it in CI.
Two models per site
Every custom-code site has two content models on the platform:
| Model | Stored in | Written by | Read by |
|---|---|---|---|
| Live model | cms_schema, one row per version | kwisple push (your machine or CI), kwisple dev --live | editors in the dashboard, the delivery API, your production and preview deployments |
| Development model | cms_dev_schema, at most one row per site | kwisple dev | only requests that send x-kwisple-schema: dev |
The development model lets you add a section or a block, see it typed in your editor and rendered by your dev server, while editors and the live site keep working with the live model. Nothing an editor sees changes until you push.
The SDK sends x-kwisple-schema: dev when schemaEnv is "dev", which defaults to the
KWISPLE_SCHEMA environment variable. kwisple dev sets KWISPLE_SCHEMA=dev for the dev
server it starts, so you do not configure anything. On the platform, reads with the development
model are never cached and never shared with live answers.
When the development model equals the live model it is removed: kwisple dev drops it when your
code no longer differs from live, and a production push with the same hash clears it.
kwisple dev
npx kwisple dev # watch cms.config.ts, push to the development model, start the dev server
npx kwisple dev --no-server # only follow the content model
npx kwisple dev --live # push every save straight to the live model (the old behaviour)
npx kwisple dev --config site/cms.config.tsWhat it does, in order:
- Loads
cms.config.ts(or--config <pad>) withtsx, fresh on every run, and takes the default export (or a named exportschema). - Validates the model locally. Problems stop the push and are printed under
Contentmodel klopt niet:, one line per problem (see Schema as code). - Pushes the serialised model with
env: "dev"toPOST /api/v1/schema. - Prints the result and the list of changes against the live model:
Ontwikkelmodel bijgewerkt … — live blijft op versie <n>when the development model changed;Gelijk aan het live model (versie <n>)when your code equals live (the development model is then removed).
- Watches the folder of the config file recursively. A save of a
.ts,.mts,.jsor.mjsfile pushes again after 250 ms. Paths containingnode_modules,.next,.astro,distorsrc/are ignored, socms/fields.tsandcms/blocks.tstrigger a push and your components do not. - Starts the project's dev server:
<pm> run dev, where<pm>follows the lockfile (pnpm-lock.yaml,yarn.lock,bun.lock/bun.lockb, otherwisenpm). Without adevscript inpackage.jsonno server is started.
The development model is never gated: a destructive change (a removed field, a changed kind) is
stored right away, because nothing live changes. The gate comes at kwisple push.
A failing push while watching prints a red line with the time and the reason and keeps watching;
fix the file and save again. If the dev server exits with an error code, kwisple dev exits too.
Some dev servers detach themselves and exit with 0; then the CLI prints that the server keeps
running in the background and goes on syncing the model.
With --live, every save is a normal production push without --force. Additive changes apply
immediately; a destructive change is not applied and the CLI prints the changes and
Bevestig met kwisple push --force., then keeps watching.
Under Website → Instellingen → Ontwikkeling (development settings), the Contentmodel card shows a pending development model as Ontwikkelmodel (kwisple dev): when and by whom it was last pushed and up to twelve changes against live, destructive ones in red. Weggooien (discard) removes it after a confirmation.
kwisple push
npx kwisple push # apply cms.config.ts to the live model
npx kwisple push --dry-run # show what would change, apply nothing
npx kwisple push --force # also apply destructive changespush loads and validates the file exactly like dev, then sends it to the live model. The
platform:
- validates the model again (same checks as the SDK);
- compares it with the latest version and lists the changes;
- compares the hash of your model with the hash of that version: equal means unchanged, nothing is stored (and an equal development model is cleared);
- stops when the push is a dry run, or when a change is destructive and
forcewas not set; - otherwise stores the next version, creates the entry of every new
singlesection, and purges the platform's cached delivery answers for the site.
Output and exit codes
| Situation | Output | Exit code |
|---|---|---|
| Applied | Versie <n> toegepast and the changes | 0 |
| Nothing changed | Geen wijzigingen — platform staat op versie <n>. | 0 |
--dry-run | Deze wijzigingen zouden worden toegepast: and the changes | 0, or 2 when a change is destructive |
Destructive, no --force | Niet toegepast: deze push bevat destructieve wijzigingen., the changes, Inhoud wordt nooit verwijderd; ze is alleen niet meer zichtbaar zolang het veld ontbreekt. | 2 |
| Model invalid | Contentmodel klopt niet: and the problems | 1 |
| Any other error (missing variables, token, network) | the reason, with status and code for API errors | 1 |
In the change list + is an addition, − a removal and ~ a change; destructive lines end in
(destructief).
A push needs a token with the schema scope (Contentmodel pushen). Without it the API answers
Dit token heeft geen "schema"-rechten. Pushing only works for custom-code sites; for a hosted
site the platform answers 409 with
Deze site is een gehoste site met het standaardschema; schema's pushen kan alleen voor sites met een eigen repo.
A push stores a new version only; it does not call your site's webhook. Your deployment, which normally follows the push in CI, or Cache legen refreshes what the site has cached (see Caching).
Destructive and non-destructive changes
The platform diffs the previous version against yours (diffSchemas in @kwisple/cms) and marks
each change. Destructive means existing content may stop showing; it never means content is
deleted.
| Change | Code | Destructive |
|---|---|---|
| Section added | section.added | no |
Section changes type (single, channel, structure) | section.retyped | yes |
Section uri format changes | section.uri | no: URIs are recomputed and old URLs redirect |
| Section removed | section.removed | yes |
| Entry type allowed in a section | entryType.allowed | no |
| Entry type no longer allowed in a section | entryType.disallowed | yes |
| Field added | field.added | no |
Field renamed with renamedFrom | field.renamed | no |
| Field kind changed (also when renamed at the same time) | field.retyped | yes |
Field translation changed | field.translation | yes |
| Other field settings (label, instructions, limits, options) | field.settings | no |
| Field removed | field.removed | yes |
| Global set added / removed | global.added / global.removed | no / yes |
| Form added, changed, removed | form.added, form.changed, form.removed | no |
| App added, changed, removed | app.added, app.changed, app.removed | no |
Changes are listed most destructive first. Field changes are reported per entry type and per
global set, so a field shared through the field library shows up once for every layout that uses
it. Changing translation on a field with content needs care; read
Localisation and URIs
before you force it.
Content is never deleted
A push changes the model, never the content rows:
- A removed field's values stay in the database. They are not shown, not returned by the API and not validated while the field is missing, and they come back when the field returns under the same handle.
- A removed section's entries stay stored and become invisible.
- Every read is normalised against the current model: fields missing from stored content are filled with their empty value and stored values of fields the model no longer has are left out. Adding or removing a field never breaks old entries, in the editor or in your code.
Because of this, a forced push can always be undone by pushing the previous model again.
Renaming a field
A plain rename looks like "field removed + field added" and is destructive. Tell the platform it is a rename instead:
Give the field its new handle and name the old one in
renamedFrom:const page = defineEntryType({ handle: "page", name: "Pagina", fields: { lead: f.plainText({ label: "Intro", renamedFrom: "intro" }), }, });Remove the old field from the layout. Keeping both is a validation error (
veld "lead" heet ook "intro" (renamedFrom), maar dat veld bestaat nog. Verwijder het oude veld.).Push. The change reads
Veld "intro" in entry type "page" heet nu "lead"; de inhoud blijft.and is not destructive.Update your components:
Entry<typeof schema, "pages">now haslead, notintro.
Reads, the editor and publishing show the value stored under intro as lead until an entry
is saved under the new handle; nothing is moved or rewritten. Keep renamedFrom until every
entry has been saved once, or keep it for good: it costs nothing. renamedFrom also takes an
array for a field renamed more than once. Changing the kind in the same push is still
destructive.
kwisple status
npx kwisple statusPrints the live version and its source (Platform: versie 12 (code)), then runs a dry-run push
of your local model: either Lokaal model is gelijk aan het platform. or the list of changes and
Voer `kwisple push` uit om deze wijzigingen toe te passen. It always compares with the live
model, not the development model.
The model check (code ↔ platform)
A deployment built from code whose model was never pushed renders against a different model than it was written for. Pass the model itself to the client to get warned:
// Next.js: src/lib/cms.ts
import { createClient } from "@kwisple/sdk";
import schema from "../../cms.config";
export const client = createClient<typeof schema>({
url: process.env.KWISPLE_URL!,
token: process.env.KWISPLE_TOKEN!,
schema,
onSchemaMismatch: "warn", // or "throw" to fail the build
});// Astro: src/lib/cms.ts
import { createKwispleClient } from "@kwisple/sdk/astro/server";
import schema from "../../cms.config";
export const client = createKwispleClient<typeof schema>({ schema });Before the client uses its first answer it computes the hash of your model (schemaHash: the
first 16 hex characters of the SHA-256 of its JSON) and compares it with the hash of
GET /api/v1/schema. When they differ it logs one line once per client:
[kwisple] Het contentmodel in je code (…) is niet wat het platform live (versie <n>) gebruikt (…). Voer `kwisple push` uit.
Under kwisple dev it compares with the development model and suggests kwisple dev.
onSchemaMismatch: "throw" throws KwispleError(409, "schema_mismatch") instead.
In the dashboard, the Contentmodel card under Website → Instellingen → Ontwikkeling shows where the model comes from (uit code (cms.config.ts)), every section, entry type and field with its handle, kind, translation and whether it is required, the global sets, and the pending development model.
Schema versions
Every applied production push is a row in cms_schema:
| Column | Meaning |
|---|---|
version | 1, 2, 3 … per site; unique together with site_id |
definition | the serialised model, stored as a JSON string so field order survives |
hash | first 16 hex characters of the SHA-256 of that JSON; what the model check compares |
source | code (pushed from a site repository) or standard (the built-in model of hosted sites) |
pushed_by | the user who applied it from the dashboard; empty for pushes with a site token |
created_at | when it was applied |
GET /api/v1/schema returns { version, source, hash, schema } for the latest version (with
x-kwisple-schema: dev, the development model; it then reports version 0). The SDK exposes it
as client.remoteSchema().
The Versies (versions) list on the Contentmodel card shows the latest twenty versions,
newest first, as v<n> with date and author (CLI/systeem for token pushes) and the changes
against the version before it; red lines removed something (the content stays). A push without
model changes shows Geen wijzigingen in het model (alleen opnieuw gepusht).
Pushing from CI
Both starters ship a GitHub Actions workflow in .github/workflows/ that runs on pull requests
and on pushes to main:
| Event | Step | Effect |
|---|---|---|
| Pull request | npx kwisple push --dry-run | lists the changes in the job log; fails when the model is invalid, the token is wrong or a change is destructive |
Push to main | npx kwisple push | applies the model; a destructive change exits 2 and fails the job |
Push to main, KWISPLE_DEPLOY_HOOK set | curl -fsS -X POST "$KWISPLE_DEPLOY_HOOK" | starts the host's production build after the push |
It needs two repository secrets, KWISPLE_URL and KWISPLE_TOKEN, a site token with
read + schema. Create them with:
npx kwisple ci-token --gh # creates the token and stores both secrets with the GitHub CLI
npx kwisple ci-token # or print them once and add them yourselfci-token uses the site from your linked project (KWISPLE_SITE in .env) or --site <id|slug>.
The same token can be made in the dashboard under Hosting & deploys → Geavanceerd → CI-token maken.
Your host usually builds on every push in parallel with the workflow. For a strict "model
first, then build" order, turn off the host's automatic production builds and store its deploy
hook as the KWISPLE_DEPLOY_HOOK secret.
When the job fails with exit code 2, read the change list in the log. If the change is
intended, run npx kwisple push --force from your machine (or a manual CI job) once; the next
push to main is then unchanged. Never add --force to the default workflow.
A staging environment is a site of its own with its own token, so its deployment pushes with that token (see Staging).
Recipes
Add a field
- Add it to the entry type in
cms.config.ts;kwisple devpushes it to the development model. - Render it; the type is already there.
- Merge. CI pushes; editors see the new field after the push.
Remove a field
- Remove it from the layout and from your components.
kwisple pushis blocked with exit code2and listsVeld "…" verdwijnt uit ….- Confirm with
kwisple push --force. The values stay stored and return if you add the field back.
Change a field's kind
There is no conversion. Prefer a new field with a new handle, let editors fill it, then remove the
old one. If you must change the kind in place, the push is destructive and needs --force.
Move from the development model to live
kwisple push (or a merge to main). If the pushed model equals the development model, the
development model is cleared and Ontwikkelmodel (kwisple dev) disappears from the card.
Where to go next
- Schema as code: the DSL and its validation.
- Site kits: seed and export: starting content for a new model.
- CLI reference and CI and automation.
- Troubleshooting.