Kwisple
Browse the docs

Content model

Workflow: dev, push and changes

How a change to cms.config.ts travels from your editor to the live content model with kwisple dev, kwisple push and CI, and why a push never deletes content.

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:

ModelStored inWritten byRead by
Live modelcms_schema, one row per versionkwisple push (your machine or CI), kwisple dev --liveeditors in the dashboard, the delivery API, your production and preview deployments
Development modelcms_dev_schema, at most one row per sitekwisple devonly 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.ts

What it does, in order:

  1. Loads cms.config.ts (or --config <pad>) with tsx, fresh on every run, and takes the default export (or a named export schema).
  2. Validates the model locally. Problems stop the push and are printed under Contentmodel klopt niet:, one line per problem (see Schema as code).
  3. Pushes the serialised model with env: "dev" to POST /api/v1/schema.
  4. 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).
  5. Watches the folder of the config file recursively. A save of a .ts, .mts, .js or .mjs file pushes again after 250 ms. Paths containing node_modules, .next, .astro, dist or src/ are ignored, so cms/fields.ts and cms/blocks.ts trigger a push and your components do not.
  6. Starts the project's dev server: <pm> run dev, where <pm> follows the lockfile (pnpm-lock.yaml, yarn.lock, bun.lock/bun.lockb, otherwise npm). Without a dev script in package.json no 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 changes

push loads and validates the file exactly like dev, then sends it to the live model. The platform:

  1. validates the model again (same checks as the SDK);
  2. compares it with the latest version and lists the changes;
  3. 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);
  4. stops when the push is a dry run, or when a change is destructive and force was not set;
  5. otherwise stores the next version, creates the entry of every new single section, and purges the platform's cached delivery answers for the site.

Output and exit codes

SituationOutputExit code
AppliedVersie <n> toegepast and the changes0
Nothing changedGeen wijzigingen — platform staat op versie <n>.0
--dry-runDeze wijzigingen zouden worden toegepast: and the changes0, or 2 when a change is destructive
Destructive, no --forceNiet toegepast: deze push bevat destructieve wijzigingen., the changes, Inhoud wordt nooit verwijderd; ze is alleen niet meer zichtbaar zolang het veld ontbreekt.2
Model invalidContentmodel klopt niet: and the problems1
Any other error (missing variables, token, network)the reason, with status and code for API errors1

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.

ChangeCodeDestructive
Section addedsection.addedno
Section changes type (single, channel, structure)section.retypedyes
Section uri format changessection.urino: URIs are recomputed and old URLs redirect
Section removedsection.removedyes
Entry type allowed in a sectionentryType.allowedno
Entry type no longer allowed in a sectionentryType.disallowedyes
Field addedfield.addedno
Field renamed with renamedFromfield.renamedno
Field kind changed (also when renamed at the same time)field.retypedyes
Field translation changedfield.translationyes
Other field settings (label, instructions, limits, options)field.settingsno
Field removedfield.removedyes
Global set added / removedglobal.added / global.removedno / yes
Form added, changed, removedform.added, form.changed, form.removedno
App added, changed, removedapp.added, app.changed, app.removedno

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:

  1. 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" }),
      },
    });
  2. 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.).

  3. Push. The change reads Veld "intro" in entry type "page" heet nu "lead"; de inhoud blijft. and is not destructive.

  4. Update your components: Entry<typeof schema, "pages"> now has lead, not intro.

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 status

Prints 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:

ColumnMeaning
version1, 2, 3 … per site; unique together with site_id
definitionthe serialised model, stored as a JSON string so field order survives
hashfirst 16 hex characters of the SHA-256 of that JSON; what the model check compares
sourcecode (pushed from a site repository) or standard (the built-in model of hosted sites)
pushed_bythe user who applied it from the dashboard; empty for pushes with a site token
created_atwhen 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:

EventStepEffect
Pull requestnpx kwisple push --dry-runlists the changes in the job log; fails when the model is invalid, the token is wrong or a change is destructive
Push to mainnpx kwisple pushapplies the model; a destructive change exits 2 and fails the job
Push to main, KWISPLE_DEPLOY_HOOK setcurl -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 yourself

ci-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

  1. Add it to the entry type in cms.config.ts; kwisple dev pushes it to the development model.
  2. Render it; the type is already there.
  3. Merge. CI pushes; editors see the new field after the push.

Remove a field

  1. Remove it from the layout and from your components.
  2. kwisple push is blocked with exit code 2 and lists Veld "…" verdwijnt uit ….
  3. 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

Last updated 2026-10-30