concepts

Content types, draft vs published, locales, tenants, and delivery tokens — the five ideas the rest of ChiliCMS is built on.

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 kindstoresnotes
stringtextrequired, maxLength, minLength
slugtextderived from a sibling field, unique within the tenant
richTextTiptap JSONflavor: "tiptap"; long-form docs stay MDX-in-repo
datetimetimestamptza non-required one drives the publish lifecycle
booleanbooleanoptional default
localetextenumerated against the locale allowlist

Schema changes are migrations. pepper push --apply introspects the live database, diffs it against your cms/*.ts files, 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:

  • draftpublishedAt is null. Visible only in the Studio.
  • scheduledpublishedAt is set but still in the future. Held back until that moment arrives.
  • publishedpublishedAt is 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 a status of active or suspended; suspending one fails its delivery closed.
  • delivery_token — the lookup that resolves an opaque token to a tenant_id, which has to run before any SET LOCAL app.tenant_id can. 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.