Your content model is a file in your repository

Content types are written in TypeScript, in your codebase, next to everything else. They generate real Postgres tables, they go through review like any other change, and they roll back the same way.

For the engineer who has to say yes to this

A declaration, and the table follows

A content type is a call to collection() or single() with a bag of fields. It lives in your application, it is imported by your server, and it is the only description of that type anywhere — there is no second copy in a database that can disagree with it.

At build time each type becomes one content_<name> table, plus one join table per many-relation that keeps the order of the links. Every table carries the same envelope — id, workspace, created and updated — and a publishable type also gets a status and a published date.

The admin form is generated from the same declaration. Nobody draws the form, and nobody keeps it in step — a field you add in the file is a field in the editor on the next boot, in the tab you put it in.

A collection file on the left and the Postgres table it generates on the right, column by column.

The envelope columns are not in the file: publishable: true is what adds status and published_at.

Twelve field types

All of them available on every content type, none of them a plugin you install.

Field What it stores
text A string, with optional minimum and maximum length.
richtext A structured document in a jsonb column, not an HTML string.
number Integer or decimal, with an optional range.
money An amount, kept exact rather than as a float.
boolean True or false. A required boolean gets DEFAULT false.
date A calendar date with no time attached.
datetime A moment, stored with its time zone.
select One value from a list you define.
multiselect Several values from that list, as a jsonb array.
json An arbitrary document, when the shape is genuinely yours.
relation A link to another content type, in one of four cardinalities.
media One asset or several from the media library, stored as ids in the values bag.

Every builder also takes a lang — the language this field is written in, when it differs from the entry’s own. A malformed tag is rejected when the type is defined, not when a page is rendered.

Two sides of a relation, one row of storage

Two storage forms cover all four cardinalities. A plain relation is a foreign key on the owning row; a many-relation generates a join table. Everything else is those two seen from a different angle.

The other side is declared with relationInverse, and it emits no column and no join table at all: reads and writes reuse the owning side’s storage with source and target swapped. Editing an article’s tags and editing a tag’s articles move the same rows, so the two sides cannot drift apart the way two mirrored columns eventually do. Adding an inverse produces no migration, because there is nothing new to store.

The registry checks the pairing when the server boots, so a typo there fails the boot rather than the first request.

Four cardinalities from two storage forms

Shape How it is declared
many-to-one A plain single relation — field.relation({ to }). The foreign key sits on this row.
one-to-many The inverse of that. It owns no storage; model it as the single relation on the "many" side.
one-to-one A single relation with unique: true.
many-to-many many: true, which generates the join table and keeps the ordering.

onDelete defaults to cascade when the relation is required and set-null when it is not. A required single relation with set-null is rejected when the type is defined, because a NOT NULL foreign key cannot be nulled.

Adding a field is an ordinary change

The same four steps as any other change, in the same tooling.

  1. Edit the collection file

    Add the field to the type. TypeScript infers the entry shape from the declaration, so anything reading that entry updates its types at the same moment.

  2. Generate the migration

    Your application owns the migrations, not the CMS. The SQL lands in your repository as a file you can read before you run it.

  3. Review it

    The schema change and the SQL arrive in the same pull request as the code that depends on them — a reviewer sees the new column and the template that reads it together.

  4. Deploy and migrate

    The standard migrate command applies it alongside every plugin’s own. Rolling back is rolling back a deploy, because there is no second place the change was made.

The review nobody dreads

Marketing wants a read time on articles. Four lines in article.ts, one generated SQL file, one template change — one pull request, one reviewer, one deploy. No separate admin session where somebody adds a field and forgets to tell anyone, and no environment where the field exists and the code does not.

What engineers ask first

Do I get real tables, or one big JSON blob?

Real tables. One per content type, a column per scalar field, foreign keys for single relations and a join table per many-relation. Query them with psql and join them to the rest of your database.

What about required fields on a draft?

On a publishable type, required means required in order to publish, so those columns stay nullable and the check runs at publish time — otherwise an incomplete draft could not be saved at all. On a type with no publish state every row is live, so required fields are NOT NULL in the database.

Can two collection files import each other?

Yes. A relation takes a thunk rather than a value for exactly that reason. Mutually-referencing files create a TypeScript inference cycle, broken by annotating one thunk’s return type.

Is the schema editable by non-engineers?

No, and that is the trade. Everything about an entry is editable in the browser; the shape of an entry is a code change. If you need marketing to add fields without a deploy, this is the wrong CMS and we would rather you found that out here.

Every feature is included, free

The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.