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
| Package | Role | Runs 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.
| Table | Holds |
|---|---|
cms_schema | Versioned 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_schema | The development model a developer is working on with kwisple dev; never affects live reads. |
cms_entry | Section, 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_content | One row per entry and locale: title, slug, URI, enabled, the translatable field values, SEO, search_text. |
cms_draft | Full snapshots: one provisional draft per user and entry (autosave), plus named drafts. |
cms_revision | A snapshot of what went live on every publish (number, creator_id, note). |
cms_relation | The relation index (entries, assets, links); drives relatedTo queries and cache purges. |
cms_global | Global set values (* for shared values, else per locale). |
cms_redirect, cms_not_found | Redirects and the 404 log. |
cms_api_token | Hashed 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:
| Function | What 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, duplicateEntry | Structure moves (URIs of descendants follow) and copies. |
listRevisions, revertToRevision | History; reverting loads a revision into a draft, a publish makes it live. |
trashEntry, restoreEntry, purgeEntry, emptyEntryTrash, listTrash, purgeExpiredEntries | The 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
- Compiles the current schema (
getSchema) and loads the entry. A section that no longer exists in the model is an error. - Normalises the snapshot: values are run through
normalizeFields(drops unknown fields, fills missing ones, applies renames) andsanitizeFieldValues(rich text through the allowlist sanitiser), then split into shared and per-locale values withsplitByTranslation. A client that puts a value in the wrong bucket is corrected. - Validates with
validateSnapshot: required fields (only for enabled locales), character limits, min/max relations and blocks, allowed block types. Errors come back asFieldError[]and nothing is written. - Sets
post_datewhen 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). - 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), writescms_entry_content, and when the URI of a live entry changed, writes a 301 (autoRedirect) and recomputes descendants' URIs (updateDescendantUris). - Rewrites the relation index (
writeRelations), appends acms_revisionwith what actually went live (computed slugs and post date included), and deletes the draft it came from. - Returns
{ ok, errors, revision, tags, redirects }, wheretagsare 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:
| Function | What |
|---|---|
listRedirects, upsertRedirect, deleteRedirect | Manual 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, dismissNotFound | The 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:
validateSchema(def): problems stop the push (problemsin the result).diffSchemas(current, def)lists the changes; each is markeddestructiveor not.- An identical
hashis a no-op (unchanged: true). - Destructive changes need
force; without it the result carries the changes andapplied: false, so the CLI or the dashboard can show them and ask. - A new
cms_schemarow withversion + 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.
| Tag | Made by | Covers |
|---|---|---|
cms:site:<siteId> | tags.site | every CMS read of a site (delivery API and hosted renderer) |
cms:entry:<entryId> | tags.entry | one entry |
cms:uri:<siteId>:<locale>:<uri> | uriTag | the "which entry lives here" lookup |
cms:section:<siteId>:<handle> | tags.section | lists 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> | webTags | apps/web's own data: site row, host lookups, brand kit, agency credit, privacy |
kwisple:all | SDK_ALL_TAG | every SDK response in a custom-code site |
Rules that live in packages/content/src:
- One list of every tag of a site:
siteCacheTags(scope)andallSiteCacheTags(wdb, ids)incache-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)inrevalidation.ts. Entry and URI tags (and anything inhard) 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:
softCacheTagsmarksagency:<id>stale only in full purges, because every client of the agency shares it. - Webhook tags:
webhookPurgeTags(scope)giveskwisple:allplus the site-levelcms:tags and entry tags, capped at 200. - Batches:
apps/webaccepts at most 100 tags per call;chunkTagssplits 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'swebhookSecret, hex. Sites verify it with the SDK's revalidate route;verifyWebhookSignatureis the server-side counterpart. - Sent through
safeFetchwith a 5 s timeout. In production onlyhttpsand public addresses (webhookGuardOptions;OUTBOUND_ALLOW_PRIVATE=1opts 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:
| Helper | What |
|---|---|
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:
| Export | What |
|---|---|
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/ |
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, presignUpload | Object operations. |
contentDispositionFor(type, key) | Raster images inline, everything else attachment. |
inspectImage, makeThumbnail, sharpInputOptions | sharp, 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).
- Entry type. Add a
defineEntryType({ handle, name, icon, description, fields })topackages/cms/src/standard/blocks.ts, reusing the shared fields instandard/fields.ts(heading,intro,body,image,buttons,tone,pattern, …). Labels are Dutch. - Register it in
builderBlocksat the bottom of the same file.StandardBlock(the union of every block) picks it up from there. - English labels. Add every new
name,label,description,instructionsandplaceholdertostandardEninpackages/cms/src/standard/en.ts;packages/cms/test/standard-en.test.tsfails on a missing one. - Renderer. Add
apps/web/src/blocks/<name>.tsxexporting a component typed asBlockComponentProps<"<handle>">, built fromshared.tsx(Section,Container,H,cx, …). Uselevelfor 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.tsxleaf likecontact-form-client.tsx. - Wire it into the
renderersrecord inapps/web/src/blocks/render.tsx. The record is typedRecord<BlockType, …>, so a missing renderer is a type error. - Apply the model. Existing hosted sites get the new block when the standard schema is
pushed to them (
applyStandardSchemaAction;provisionHostedSitedoes it for new hosted sites). Adding a block type is not destructive. - Check.
pnpm --filter @kwisple/cms test,pnpm typecheck, then publish a page with the block on a seeded hosted site (pnpm db:seedcreates one) and look at it onhttp://<slug>.sites.localhost:4101.
Where to look next
- Jobs and the worker for
site.deploy,cms.scheduled,cache.purgeAll,asset.processandstorage.purge. - Caching for the same caches from a site developer's side.
- Adding things: recipes for a new endpoint.