Kwisple
Browse the docs

The platform repository › How it works

The content engine

How @kwisple/cms and @kwisple/content store, validate, publish and serve content, what happens after a publish, how apps/web delivers it and where assets live.

Every website on the platform, custom code or legacy hosted, reads its content from one engine. This page explains how that engine is built, what a publish does from the click to the visitor, and the one place where apps are allowed to touch it. The modelling side (fields, sections, URI formats) is described for site developers in Schema as code and Fields reference; this page is about the platform code behind it.

Two packages, one rule

PackageRoleRuns where
@kwisple/cms (packages/cms)The model as code: defineSchema, defineSection, defineEntryType, defineGlobalSet, defineApp, defineForm, f.* fields, stored-value Zod schemas (storedFieldSchema, fieldsSchema), normalisation (normalizeFields, splitByTranslation), URI formats (renderUri, uriToPath, pathToUri), schema diffing (diffSchemas), the rich-text sanitiser (sanitizeHtml, sanitizeFieldValues). ./standard holds the built-in schema of hosted sites.Everywhere: dashboard, apps/web, the SDK and client site repositories. Zero server dependencies (only zod); published to npm.
@kwisple/content (packages/content)The server engine on top of @kwisple/db: reads, editor state, drafts, publish, URIs, redirects and the 404 log, globals, revisions, entry trash, API tokens, preview tokens, cache tags, the publish webhook, seeds, staging.Server only: the dashboard, apps/web and the worker.

The rule from CLAUDE.md: all content reads and writes go through @kwisple/content. Apps never select from or write to a cms_* table themselves. The engine is where values are normalised against the current schema, validated, sanitised, given unique URIs and tagged for caches; a query that goes around it skips all of that and the cache never hears about the change.

The site context

Every engine function takes a SiteCtx (packages/content/src/context.ts):

interface SiteCtx {
  wdb: WorkspaceDb;      // forWorkspace(db, workspaceId)
  site: SiteRow;         // the `site` row
  schemaEnv?: "dev";     // read with the development model (`kwisple dev`), delivery API only
}

const sctx = await loadSiteCtx(ctx.wdb, siteId);

loadSiteCtx throws when the site does not exist in that workspace, is in the trash, or belongs to a client in the agency's trash. Because the context carries a WorkspaceDb, every query inside the engine is tenant-scoped by construction (see Tenancy and permissions).

Helpers on the same module: siteLocales(site) (locales and default locale from site.settings, falling back to nl), localePrefix(site, locale) ("" for the default locale, /<locale> otherwise) and the tags object described under Cache tags.

Storage: the cms_* tables

Defined in packages/db/src/schema/cms.ts. Every table carries workspace_id and site_id.

TableHolds
cms_schemaVersioned schema per site: version, definition (stored as a JSON string so field order survives jsonb key sorting), hash, source (code or standard), pushed_by.
cms_dev_schemaThe development model a developer is working on with kwisple dev; never affects live reads.
cms_entrySection, entry type, structure position (parent_id, level), post_date, expiry_date, first_published_at, deleted_at, and the shared (non-translatable) field values.
cms_entry_contentOne row per entry and locale: title, slug, URI, enabled, the translatable field values, SEO, search_text.
cms_draftFull snapshots: one provisional draft per user and entry (autosave), plus named drafts.
cms_revisionA snapshot of what went live on every publish (number, creator_id, note).
cms_relationThe relation index (entries, assets, links); drives relatedTo queries and cache purges.
cms_globalGlobal set values (* for shared values, else per locale).
cms_redirect, cms_not_foundRedirects and the 404 log.
cms_api_tokenHashed site tokens with scopes read, preview, schema.

The canonical rows (cms_entry + cms_entry_content) are the live content. Editors never write to them directly; they work in a draft and the publish applies it.

Entries, drafts and revisions

The editor flow, all in packages/content/src/write.ts:

FunctionWhat it does
createEntry(ctx, { section, … })A new entry in a section (draft state until published).
ensureSingles(ctx, userId)Makes sure every single section has its one entry.
loadEditorState(ctx, entryId, userId)The canonical snapshot, the user's provisional draft if any, and per-locale contents.
saveProvisional(ctx, …) / discardProvisional(…)Autosave into the user's provisional draft / throw it away.
validateSnapshot(compiled, ctx, snap)The checks a publish runs, usable on their own.
publishEntry(ctx, { entryId, userId, snapshot, note?, draftId? })Applies a snapshot (below).
moveEntry, duplicateEntryStructure moves (URIs of descendants follow) and copies.
listRevisions, revertToRevisionHistory; reverting loads a revision into a draft, a publish makes it live.
trashEntry, restoreEntry, purgeEntry, emptyEntryTrash, listTrash, purgeExpiredEntriesThe per-site entry trash.

CMS entries keep their own, older trash (cms_entry.deleted_at); they are deliberately not in trashTables of @kwisple/db. The client trash screen links to it.

What publishEntry does

  1. Compiles the current schema (getSchema) and loads the entry. A section that no longer exists in the model is an error.
  2. Normalises the snapshot: values are run through normalizeFields (drops unknown fields, fills missing ones, applies renames) and sanitizeFieldValues (rich text through the allowlist sanitiser), then split into shared and per-locale values with splitByTranslation. A client that puts a value in the wrong bucket is corrected.
  3. Validates with validateSnapshot: required fields (only for enabled locales), character limits, min/max relations and blocks, allowed block types. Errors come back as FieldError[] and nothing is written.
  4. Sets post_date when the entry goes live without one (one second in the past, so a database clock slightly behind the app server never hides a fresh publish).
  5. In one transaction, per locale: computes the slug (from the slug field, else the title) and a unique URI for that site and locale (-2, -3, … on collision), writes cms_entry_content, and when the URI of a live entry changed, writes a 301 (autoRedirect) and recomputes descendants' URIs (updateDescendantUris).
  6. Rewrites the relation index (writeRelations), appends a cms_revision with what actually went live (computed slugs and post date included), and deletes the draft it came from.
  7. Returns { ok, errors, revision, tags, redirects }, where tags are the cache tags of the entry (tagsForEntry).

publishEntry does not purge caches or send webhooks. That is the caller's job, through the dashboard's publish helper (next section).

Normalisation on read

Reads are normalised against the current schema too: a field that was removed simply is not returned, a new field reads as its empty value, and a re-added field brings its old values back. Together with "a push never deletes data" this is why a developer can rename or remove a field without losing an editor's work.

Sanitisation

sanitizeHtml in packages/cms/src/sanitize.ts is a small, dependency-free tokenizer that keeps p, h2–h4, ul, ol, li, strong, em, a, br and blockquote, always closes what it opens, and keeps only href on links with a safe protocol (http(s):, mailto:, tel:, /, #; never javascript:). External links get rel="noopener noreferrer" and target="_blank". Global sets go through the same sanitizeFieldValues on save (saveGlobal).

URIs and redirects

A section's URI format uses the tokens {slug}, {parent.uri}, {postDate.year}, {postDate.month} and {locale}; renderUri fills them and drops empty segments, so {parent.uri}/{slug} works for top-level entries. The home entry has the URI __home__ (uriToPath turns it into /). Locale prefixes are added by the site (localePrefix), not stored in the URI.

packages/content/src/redirects.ts:

FunctionWhat
listRedirects, upsertRedirect, deleteRedirectManual redirects (the Doorverwijzingen screen of a website).
matchRedirect(ctx, path)Exact and prefix matches; used by the delivery API (/api/v1/redirects/match) and the hosted renderer.
recordNotFound(ctx, path, referrer)The 404 log, capped at MAX_NOT_FOUND_ROWS (5000) per site.
listNotFound, dismissNotFoundThe Niet-gevonden pagina's screen, with one-click redirect.

Automatic redirects re-point older redirects to the new target, so there are never chains.

Schema versions

pushSchema(ctx, def, { source, userId, force, dryRun }) in packages/content/src/schema.ts:

  1. validateSchema(def): problems stop the push (problems in the result).
  2. diffSchemas(current, def) lists the changes; each is marked destructive or not.
  3. An identical hash is a no-op (unchanged: true).
  4. Destructive changes need force; without it the result carries the changes and applied: false, so the CLI or the dashboard can show them and ask.
  5. A new cms_schema row with version + 1. Content is never deleted; a forced push can be undone by pushing the old model back.

pushDevSchema stores the development model of kwisple dev in cms_dev_schema without any gate (nothing live changes) and is cleared as soon as an equal production push lands. Delivery requests with the header x-kwisple-schema: dev read with it, uncached.

Cache tags

Published content is served from caches and purged by tag; nothing is rebuilt.

TagMade byCovers
cms:site:<siteId>tags.siteevery CMS read of a site (delivery API and hosted renderer)
cms:entry:<entryId>tags.entryone entry
cms:uri:<siteId>:<locale>:<uri>uriTagthe "which entry lives here" lookup
cms:section:<siteId>:<handle>tags.sectionlists of a section
cms:globals:<siteId>, cms:redirects:<siteId>, cms:schema:<siteId>tags.*globals, redirects, the model
site:<siteId>, host:slug:<slug>, host:host:<hostname>, brand:<workspaceId>, agency:<agencyId>, privacy:<siteId>webTagsapps/web's own data: site row, host lookups, brand kit, agency credit, privacy
kwisple:allSDK_ALL_TAGevery SDK response in a custom-code site

Rules that live in packages/content/src:

  • One list of every tag of a site: siteCacheTags(scope) and allSiteCacheTags(wdb, ids) in cache-tags.ts. Every site-wide purge ("Cache legen", the trash, going live or offline, domain changes) uses it. A new tag kind is added there once.
  • Hard or stale: revalidationPlan(tags, hard) in revalidation.ts. Entry and URI tags (and anything in hard) expire immediately; broad tags are revalidated stale-while-revalidate, so the rest of the site keeps being served from cache while it refreshes in the background.
  • Soft agency tag: softCacheTags marks agency:<id> stale only in full purges, because every client of the agency shares it.
  • Webhook tags: webhookPurgeTags(scope) gives kwisple:all plus the site-level cms: tags and entry tags, capped at 200.
  • Batches: apps/web accepts at most 100 tags per call; chunkTags splits a list.

The publish pipeline

Every dashboard code path that changes content calls afterContentChange in apps/dashboard/src/lib/services/cms-publish.ts after the engine wrote. A publish from the entry editor (publishEntryAction in lib/actions/cms.ts) runs like this:

publishEntryAction(workspace, siteId, entryId, snapshot)
  setup(...)                       requireWorkspace + loadSiteCtx + can(ctx, "page.editContent")
  publishEntry(sctx, …)            the engine (above); errors go back to the editor
  afterContentChange(wdb, site, {
    tags, event: "cms.published", actorId, subjectId,
    hard, warmPaths               entryCacheHints(): the entry's URI tags + its public paths
  })
    1. revalidateWeb(tags + cms:site:<id>, { hard, warm })   POST apps/web /api/revalidate
    2. headless + webhookUrl → sendPublishWebhook(site, { tags, event })
    3. syncDetectedServices()     keeps the cookie banner's services up to date (background)
    4. headless → scheduleContentDeploy()   debounced `site.deploy` job for static sites
    5. logActivity(...)
  webhookPagePublished(...)        the client's outgoing `page.published` webhook (live pages)
  scheduleEntryPurges(...)         `cms.scheduled` jobs for a future "online/offline vanaf"
  revalidatePath(base, "layout")

afterContentChange never throws: the content is already saved, so failures come back in PublishEffects (revalidated, webhook: { sent, reason }, deploy: { queued, coalesced }) and the editor shows them. For headless sites every tag expires hard and the webhook goes out only after the purge, because the site refetches the delivery API the moment it hears about the change; a stale-while-revalidate answer would give it the old content. Cache warming (warmPaths) is only used for hosted sites.

Other callers: the standard-schema action (applyStandardSchemaAction, event cms.schema.pushed), globals, moves and deletes in lib/actions/cms.ts, and the cms.scheduled worker handler.

Scheduled pages: cms.scheduled

Cached pages do not notice an "online vanaf" or "offline vanaf" moment by themselves. scheduleEntryPurges (lib/services/site-cache.ts) enqueues one cms.scheduled job per future moment, startAfter five seconds after it, with a singletonKey of cms.scheduled:<entryId>:<kind>:<iso> so the same moment is only queued once. The handler (worker/handlers/cms-scheduled.ts) calls afterContentChange with the entry's tags as hard tags and event cms.scheduled; a site that went to the trash in the meantime is skipped.

The site webhook

sendPublishWebhook(site, { tags, event }) in packages/content/src/webhook.ts:

  • Body: { tags, event, siteId, at } as JSON.
  • Header x-kwisple-signature: HMAC-SHA256 of the raw body with the site's webhookSecret, hex. Sites verify it with the SDK's revalidate route; verifyWebhookSignature is the server-side counterpart.
  • Sent through safeFetch with a 5 s timeout. In production only https and public addresses (webhookGuardOptions; OUTBOUND_ALLOW_PRIVATE=1 opts in to private targets). Redirects are never followed and reported as an error.
  • Never throws; returns { sent: true } or { sent: false, reason }.

This is the cache webhook of a custom-code site. The client's own outgoing webhooks (form.submitted, page.published, …) are a separate system; see Outgoing webhooks.

apps/web: delivery API and legacy renderer

apps/web is the only app that serves content to the outside. It reads through @kwisple/content like the dashboard.

Delivery API (/api/v1/*)

Shared plumbing is in apps/web/src/lib/api/http.ts:

HelperWhat
authenticate(req, scope)Reads Authorization: Bearer <token> and calls authenticateToken(raw, scope) (tokens cached in memory for 60 s). 401 for a bad token, 403 when the token lacks the scope. Sets schemaEnv: "dev" for x-kwisple-schema: dev.
cachedRead(ctx, key, fn, extraTags)unstable_cache per site and key, tagged cms:site:<id> plus extra tags, revalidate: 3600. Development reads are never cached.
json(data, { cache })private, max-age=60, stale-while-revalidate=600 for cacheable reads, private, no-store otherwise; vary: authorization, x-kwisple-preview. Never public: answers are bearer-authenticated.
apiError(status, code, message)The one error shape { error: { code, message } }.
handle(fn)Turns thrown errors into a 500 without leaking internals.

Scopes per route: read for reads, forms, consent and the agent report; preview for /preview/verify and draft reads with x-kwisple-preview; schema for pushing a model (POST /schema) and seeding. The full endpoint list is in Delivery API.

The engine functions behind it: findEntries, findEntry, entryTree (read.ts), getGlobals (globals.ts), previewEntry (preview.ts), matchRedirect, recordNotFound, getSchema, pushSchema, seedSite, exportSeed.

/api/revalidate

POST /api/revalidate with Authorization: Bearer <REVALIDATE_SECRET> (compared in constant time) and { tags (1–100), hard?, warm? }. It applies revalidationPlan with revalidateTag(tag, { expire: 0 }) for hard tags and revalidateTag(tag, "max") for the rest, then, after the response, re-renders up to eight warm paths of a live hosted site through its own proxy with the site's public Host header. REVALIDATE_SECRET must be identical in the dashboard and the web app.

Legacy hosted renderer

Hosted sites (site.mode = "hosted") are rendered by apps/web under /-sites/[key]/[locale]/[[...slug]] (the proxy maps the Host header to a site key) as ISR pages with revalidate = false: rendered once, cached until one of their tags is purged. Live preview goes to the dynamic /-preview/… tree, so drafts never disable caching. The page builder is a matrix field whose blocks are rendered by RenderBlocks (apps/web/src/blocks/render.tsx). Hosted sites are never offered for new websites; existing ones keep working, so the renderer is maintained, not extended, unless a recipe below applies.

Assets

Uploads live in S3-compatible storage (RustFS locally, R2 or S3 in production) through @kwisple/storage:

ExportWhat
storageClient(), bucket()One S3 client from STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY (path-style), bucket STORAGE_BUCKET.
objectKey(workspaceId, folder, fileName)`workspaces//<assets
publicUrl(key)STORAGE_PUBLIC_URL + key (the CDN or asset domain).
prepareUpload(bytes)Types a file by magic bytes, rejects unknown types, sanitises SVG.
putObject, getObject, deleteObject, deletePrefix, objectExists, presignUploadObject operations.
contentDispositionFor(type, key)Raster images inline, everything else attachment.
inspectImage, makeThumbnail, sharpInputOptionssharp, capped at 40 megapixels.

Layout in the bucket:

workspaces/<workspaceId>/
  assets/    uploads (media library, brand logos and elements)
  fonts/     uploaded and self-hosted fonts
  renders/   social post renders
  exports/   zips and PDFs (workspace export, brand guide, brand pack)

Because every key starts with the workspace, deleting a client is a prefix delete: the storage.purge job (payload { prefix: "workspaces/<id>/" }, validated by a regex) runs deletePrefix.

The upload flow (apps/dashboard/src/lib/services/assets.ts): prepareUpload, putObject under objectKey, insert the asset row through ctx.row(...) with width, height, placeholder and dominant colour. A raster image wider than THUMB_WIDTH then gets an asset.process job off the request path; its handler (worker/handlers/asset-process.ts) writes a WebP grid preview and stores its key in asset.thumb_key. Until it exists the grid shows the original. Images used in content need alt text to be publishable.

Recipe: a legacy hosted block

Only for maintaining existing hosted sites. New websites are custom code, and their blocks live in their own repository (Blocks and relations).

  1. Entry type. Add a defineEntryType({ handle, name, icon, description, fields }) to packages/cms/src/standard/blocks.ts, reusing the shared fields in standard/fields.ts (heading, intro, body, image, buttons, tone, pattern, …). Labels are Dutch.
  2. Register it in builderBlocks at the bottom of the same file. StandardBlock (the union of every block) picks it up from there.
  3. English labels. Add every new name, label, description, instructions and placeholder to standardEn in packages/cms/src/standard/en.ts; packages/cms/test/standard-en.test.ts fails on a missing one.
  4. Renderer. Add apps/web/src/blocks/<name>.tsx exporting a component typed as BlockComponentProps<"<handle>">, built from shared.tsx (Section, Container, H, cx, …). Use level for the block's main heading (the first hero gets the page's only h1). Keep it a Server Component; split an interactive part into a *-client.tsx leaf like contact-form-client.tsx.
  5. Wire it into the renderers record in apps/web/src/blocks/render.tsx. The record is typed Record<BlockType, …>, so a missing renderer is a type error.
  6. Apply the model. Existing hosted sites get the new block when the standard schema is pushed to them (applyStandardSchemaAction; provisionHostedSite does it for new hosted sites). Adding a block type is not destructive.
  7. Check. pnpm --filter @kwisple/cms test, pnpm typecheck, then publish a page with the block on a seeded hosted site (pnpm db:seed creates one) and look at it on http://<slug>.sites.localhost:4101.

Where to look next

Last updated 2026-10-30