concepts
Five ideas carry the whole product: content types defined as code, the draft-vs-published lifecycle, per-row locales, tenant isolation enforced by Postgres, and the delivery tokens that read it all back. Once they click, the rest of ChiliCMS is predictable.
content types are code
Your content model is canonical in code, not in a GUI. You author each type in a cms/*.ts file with defineContentType, and the Studio’s editing UI is generated from it. The schema lives in version control, gets reviewed in pull requests, and diffs cleanly across environments.
import { defineContentType, defineField } from "@chilicms/db"
import { varchar, jsonb, timestamp } from "drizzle-orm/pg-core"
export default defineContentType({
modelName: "Article",
label: "Articles",
description: "Long-form writing.",
userFields: {
title: defineField({
column: varchar("title", { length: 200 }).notNull(),
meta: { kind: "string", required: true, maxLength: 200 },
}),
body: defineField({
column: jsonb("body").notNull(),
meta: { kind: "richText", required: true, flavor: "tiptap" },
}),
publishedAt: defineField({
column: timestamp("published_at", { withTimezone: true }),
meta: { kind: "datetime", required: false },
}),
},
})You only spell out userFields. Five system fields (id, tenantId, createdAt, updatedAt, and locale) are injected into every type, so you never re-declare the bookkeeping columns by hand.
Each field carries two things at once: a Drizzle column (what Postgres stores) and a meta object (what the CMS knows about it). The meta.kind is the field’s CMS type: string, slug, richText, datetime, boolean, uuid, or locale. SQL can express “this is a varchar(200),” but only the meta can express “this is a slug derived from title” or “this is Tiptap rich text.” Codegen reads both.
| field kind | stores | notes |
|---|---|---|
string | text | required, maxLength, minLength |
slug | text | derived from a sibling field, unique within the tenant |
richText | Tiptap JSON | flavor: "tiptap"; long-form docs stay MDX-in-repo |
datetime | timestamptz | a non-required one drives the publish lifecycle |
boolean | boolean | optional default |
locale | text | enumerated against the locale allowlist |
Schema changes are migrations.
pepper push --applyintrospects the live database, diffs it against yourcms/*.tsfiles, and writes the SQL plus the RLS policies. Nothing touches your tables without a migration you can read.
draft vs published
A content type that has a non-required datetime field (by convention publishedAt) has a publish lifecycle. A type without one does not: every row of it is live the moment it exists.
For types that do have the lifecycle, a row’s state is computed from publishedAt, not stored as a separate flag:
- draft —
publishedAtisnull. Visible only in the Studio. - scheduled —
publishedAtis set but still in the future. Held back until that moment arrives. - published —
publishedAtis set and is now or in the past. Served by the delivery API.
The public delivery endpoints return only published rows. The Studio and the GraphQL admin surface list everything, so editors see their drafts. The Studio marks live rows with a dedicated green state and a Live label, so color is never the only signal. Unpublishing clears publishedAt again — one click, reversible.
locales
Every row carries a locale. The column defaults to en and is constrained, at the database layer, to a fixed allowlist: en, de, fr, es, nl, and it. The API rejects anything outside that set with a 400 before it reaches a query.
Locale is part of how rows stay distinct. The uniqueness index on a slug is composite, (tenant_id, slug, locale), so the same slug can exist once per locale within a tenant. An English hello-world and a German hello-world are two separate rows, not a collision.
When you read content, pass the locale you want:
const articles = await chili.list("articles", { locale: "de" })Omit it and you get every locale for that type. Because a slug is unique only per locale, pass locale to getBySlug when you want a specific translation.
tenants and row-level security
One ChiliCMS instance serves many tenants, and the isolation between them is enforced by Postgres, not by application code that could forget a WHERE clause.
Every content table has row-level security forced on. Middleware sets SET LOCAL app.tenant_id per transaction, derived only from a verified credential, and the database hides every row that does not match. A bug in a query handler cannot leak another tenant’s rows, because the rows are invisible at the database level before the handler ever sees them.
Two tables sit outside that rule by design, because they define the boundary rather than living inside it:
tenant— the registry of who exists. A tenant row is a tenant boundary, so it cannot itself be tenant-scoped. A tenant has astatusofactiveorsuspended; suspending one fails its delivery closed.delivery_token— the lookup that resolves an opaque token to atenant_id, which has to run before anySET LOCAL app.tenant_idcan. It is the pre-check that establishes which tenant a request belongs to.
Both are read only through SECURITY DEFINER functions. The application role holds no direct grant on either, not even SELECT, so app code can never enumerate the cross-tenant token map.
delivery tokens
A delivery token is a read-only, tenant-scoped bearer credential for the public delivery API. It returns published content for exactly one tenant and can do nothing else: no writes, no drafts, no other tenants.
The plaintext token is shown to you once, at creation, and never stored. The database keeps only its SHA-256 hash. When a request arrives, ChiliCMS hashes the bearer, looks up the matching row, and resolves it to a tenant_id. The resolver returns nothing for a revoked token (revokedAt is set) or a suspended tenant, so revocation and suspension both fail closed.
You give the token to the SDK once:
import { createClient } from "@chilicms/sdk"
export const chili = createClient({
baseUrl: process.env.CHILICMS_API_URL,
})
const posts = await chili.list("posts", { locale: "en" })Reads land on the public delivery surface at /api/v1/<type>. Between the token’s tenant scope and the forced row-level security on every content table, a delivery token sees published rows for its own tenant and nothing else: the database, not the application, draws the line.
Continue to the sdk reference or self-host.