The admin’s Content Model page edits your content types the way you would
by hand. It writes the TypeScript under apps/server/src/content/, has
drizzle-kit generate the migration, runs it, and waits for the dev watcher to
restart the server. What lands in your repository is a type file, a
regenerated manifest and a migration — review and commit them like any other
change.
New in 0.8.0, and in every app generated since. An older app wires it in with the steps in upgrading.
It edits code, not a runtime schema
There is no schema in the database and no way to change a running process.
A change takes effect when the server restarts on the new code, and editing
works only in development. In production the same page is a read-only map
of the model, and a model change ships the way it always did: commit, deploy,
orthacms migrate.
Turning it on
Every app registers the builder. The page is always there — Content Model
in the sidebar’s Directory group, at /content-model — for anyone holding
content:read. It is global: content types are the same in every workspace.
Editing needs all of these:
| Condition | Default in a generated app |
|---|---|
SCHEMA_BUILDER=true | false in .env |
NODE_ENV is not production | unset |
The source tree on disk: the generated src/content/index.ts under SCHEMA_BUILDER_ROOT | apps/server, resolved from where npm run dev starts |
You hold schema:manage | administrators only |
# .env — on your own machine, never on a server
SCHEMA_BUILDER=trueThen npm run dev and open Content Model. Without one of the conditions
the page stays read-only and says why:
| The page says | Fix |
|---|---|
| This server runs in production | Change the model in development and deploy it |
| Editing is off | Set SCHEMA_BUILDER=true and restart the dev server |
| This server has no source files to edit | Run it from its project (npm run dev), or set SCHEMA_BUILDER_ROOT |
| The content manifest was written by hand | Run orthacms content sync once so the builder owns src/content/index.ts |
Changing it needs the schema:manage permission | Ask an administrator |
The admin’s read-only state is a courtesy. The server decides: plan and apply
answer 403 schema-builder.disabled whenever editing is off, whatever the
caller’s role.
A walkthrough
1. Add a type
New content type, at the foot of the type list, opens a dialog. Name the type (the machine name follows the name until you type one), pick its Kind — a Collection holds many entries, a Page one — and Start from: Blank, or a template.
| Template | Fields |
|---|---|
| Article | title (required), slug (required), body (rich text), cover (media) |
| Event | title (required), starts (date & time, required), ends, place |
| FAQ | question (required), answer (long text) |
A template is only fields of the DSL, as if you had added them one by one, and every one of them can be edited or removed before the apply. Features are checkboxes — Draft & publish and Trash on, Localized off by default. Create type puts the type in your draft; nothing is created until you apply. The machine name is the table name: lowercase letters, digits and underscores, and fixed once applied. A page’s path and the description are set in the type’s Settings.
On an existing type only the trash can be turned on; every other flip needs a data migration (see what it refuses).
2. Add a field
Since 0.13.0 the type’s page is its fields. Its heading is the type’s label, with one line under it — machine name, kind, the features that are on, the path of a page, the field count — and Settings, a sheet with the label, description and features. Its ⋯ menu removes the type. Each field row is its label over its machine name, a badge only for what changes behaviour (Required, Localized, Hidden, Multiple), and the kind in words on the right.
Add field opens Add a field to {Type}: every kind, under Text, Numbers and dates, Choices and Links, each saying what it is for — Short text, Long text, Rich text, JSON; Number, Money, Date, Date & time; Yes / no, One of a list, Several of a list; Relation, Media. Search them by name or example (“date”, “image”, “tags”), and Continue — or press Enter, or double-click — to the field’s panel.
That is the same panel a field is edited in, with General, Validation and Display tabs and a Preview — how editors see it that is redrawn on every keystroke: label, required mark, locale globe, the control its kind draws, placeholder or default, help text.
- General — label, machine name (it follows the label until you type one), required, localized; for One of a list and Several of a list, the options and then the default, picked from them — and for a relation, what it links to and how many.
- Validation — the rules this kind takes.
- Display — help text, placeholder, width, group and hidden.
The field joins your draft only when you press Add field; Another kind goes back to the picker, and leaving at either step leaves nothing half-made. The three-step add-field page is gone.
Long text is a preset, not a type: a richtext field with the textarea
widget and structure checks off — how the DSL says “a plain box of text”.
Relations are offered by what they mean:
| Choice | Stored as | Like |
|---|---|---|
| Many-to-one | A column on this type | Many posts by one author |
| One-to-one | A unique column on this type | A product and its spec sheet |
| Many-to-many | A separate join table | Posts and tags |
The builder offers exactly the options the DSL has — every control maps to
one option of collection(), single() or a field builder. A default value
is there, as the DSL has it: it prefills a new entry’s form and is never a
column default. No option labels, scalar uniqueness or conditional fields,
because the DSL has none yet. There is no control picker (the kind is the control) and no row key
(two adjacent half-width fields share a line on their own). A widget, row
or unknown admin key a type already carries is kept as it is.
Every edit is checked with the same rules the DSL runs at boot, so a broken rule shows under the field as you type, in the DSL’s own words. Fields are listed the way the entry editor’s one Content form draws them — relations and media are fields like any other — and a type’s groups are accordion blocks in it. Drag a field by its handle, or move it from the keyboard — into a group, out of one, or within a list; the list it is over makes room for it while it is still held. Groups are dragged into order by their own handle, and each one’s menu edits or removes it (removing a group keeps its fields, loose). Renaming the option a select’s default names carries the default along; removing it clears the default.
3. Review
The draft’s account sits in the top bar: how many unsaved changes, how many problems, Discard changes and Review changes. The review is one page — the same page, so Back to editing (or the browser’s Back) returns to your draft untouched:
- Changes, one card per type with its riskiest verdict, and every change in it with its own verdict and the reason. A change that deletes data is drawn in red and has its own I understand this deletes data checkbox. There is no “confirm all”.
- Technical details, folded: the Files an apply writes under
src/content, and the SQL drizzle-kit generated, exactly as it will run. Nothing is written yet. - At the foot, the Migration name and Apply N changes. A name is
suggested from your changes (
add_recipe,update_recipe); it must match^[a-z][a-z0-9_]{0,59}$.
Apply stays disabled, with the reason beside it, until nothing is blocked, every destructive change is ticked and the name is valid.
4. Apply
The server takes a lock, checks everything again, and answers at once. Then, in order:
- Generate. drizzle-kit writes the migration into
migrations/— when the change needs one; a code-only change skips this and the next step. - Migrate. Every pending content migration runs in one transaction.
- Publish. A
schema.appliedevent is recorded, and the new files are written intosrc/content/— once, after the migration committed. - Restart. The dev watcher restarts the server on the new code. The page waits for the new process, then your draft starts over from the new model.
The page follows it as a stepper — Generating the migration, Running the migration, Writing N files, Restarting the server, Ready — with each step’s time as the tab saw it. Then it says what was Applied: the changes, the migration, how long the restart took.
If nothing restarts the server on a change to src/ — you run it some other
way — set SCHEMA_BUILDER_RESTART=manual and the progress asks you to restart
it yourself.
A failure keeps your draft and says so. Before the publish step nothing changed: the migrations folder is restored and the transaction rolled the database back.
5. Grant it
A new type is granted to no workspace. The applied page offers it in place — Use {Type} in workspaces, with the active workspaces as tiles (a search past six) and Grant to N workspaces or Not now, as equal answers. Either answer leaves a line saying what happened. “Not now” grants nothing; grant it later from the workspace’s Settings, under Content types. Then Open {Type} in the model.
Verdicts
Every change in the review carries one of four verdicts:
| Change | Verdict | Shown as | Migration |
|---|---|---|---|
| New type | safe | Safe | yes |
| Label, description, page path, groups, field order, display options | safe | Safe | no |
| Field added — optional, on a publishable type, or a boolean | safe | Safe | yes |
| Trash turned on | safe | Safe | yes |
A validation rule changed (min…, pattern, options, …) | data | Check data | no |
required changed on a non-publishable type | data | Check data | yes |
Relation target, cardinality, unique, onDelete, inverse | data | Check data | yes |
| Field removed | destructive | Deletes data | yes |
| Type removed, unused | destructive | Deletes data | yes |
| Type removed while granted or referenced | blocked | Not applied | — |
| Required field added to a non-publishable type that has entries | blocked | Not applied | — |
| Field renamed | blocked | Not applied | — |
| Field retyped | blocked | Not applied | — |
| Localized or Draft & publish toggled, trash turned off | blocked | Not applied | — |
data— the change applies, but existing entries may no longer pass validation. Check them.destructive— drops a column or a table and the values in it. Each one is confirmed on its own.blocked— the review shows why, with no files and no SQL. Undo the change to continue; an apply of a blocked draft is refused (422 schema-builder.blocked).
On a non-publishable type required is a NOT NULL column, which is why a
required field cannot be added to one that already has rows — the DSL has no
default value to fill them with.
What it refuses
Each of these needs a data migration the builder would have to guess, so it does not try. Do them by hand:
| You want to | Do instead |
|---|---|
| Rename a field and keep its values | Edit the type file, orthacms generate, and make sure the SQL renames rather than drops — drizzle-kit asks. Or add a new field and remove the old one, losing the values |
| Change a field’s type | Edit the type file, generate, and adjust the SQL so existing values convert |
| Toggle Localized or Draft & publish, or turn the trash off | Edit the type file and write the data migration that goes with it |
| Remove a type in use | Remove the relations that point at it and revoke its workspace grants first — a grant on a type that still has entries cannot be revoked |
| Add a required field to a live type with entries | Add it optional, fill every entry, then make it required — a data change |
The by-hand path is the one in migrations: edit,
orthacms generate, read the SQL, orthacms migrate. Commit the two together.
Who owns a file
The builder edits only type files whose first line is the marker:
// @orthacms-generated — managed by the Schema Builder.A type without it is Written by hand: shown, never rewritten. A draft that
touches one is refused with 422 schema-builder.not-owned, and the page says
how to hand it over.
- Hand a type over — add
// @orthacms-generatedas its first line. - Take a type back — delete that line. The builder then shows it read-only.
A file the builder writes is rendered whole from the model, formatted with your app’s prettier. Comments and helpers of your own do not survive that, so hand a file over only when you are happy for it to read like generated code. Type files may import the DSL, packages and each other — nothing else relative.
The type file has to sit at the conventional path,
collections/<name>.ts or pages/<name>.ts, exporting a type named <name>.
Anywhere else, the builder treats it as hand-written.
What it writes
Paths are relative to the server app, apps/server:
| Path | Written by | Notes |
|---|---|---|
src/content/collections/<name>.ts | the builder, or you | One file per collection. Deleted when you remove a builder-owned type |
src/content/pages/<name>.ts | the builder, or you | One file per single |
src/content/index.ts | the builder, or orthacms content sync | The content manifest. Never edit it by hand |
migrations/, migrations/meta/ | drizzle-kit | Your content migrations, tracked in __drizzle_migrations_content |
.orthacms/ | the builder | Scratch work: plans, applies and their backups, the lock, the operation log. Gitignored; never under src/ |
A collection the builder created:
// apps/server/src/content/collections/recipe.ts
// @orthacms-generated — managed by the Schema Builder.
// Remove the line above to take this type over by hand; the builder will then show it read-only.
import {
collection,
field,
type AnyContentType
} from '@orthacms/content-server/define';
import { author } from './author';
export const recipe = collection('recipe', {
label: 'Recipes',
publishable: true,
fields: {
title: field.text({ required: true, admin: { label: 'Title' } }),
summary: field.richtext({
structure: 'off',
admin: { label: 'Summary', widget: 'textarea' }
}),
cookingMinutes: field.number({
integer: true,
min: 0,
admin: { label: 'Cooking minutes', width: 'half' }
}),
author: field.relation({
to: (): AnyContentType => author,
admin: { label: 'Author' }
})
}
});The first comment line is the file’s real first line — the path comment above it is only for this page. DSL defaults are left out, so the file reads like one written by hand.
Migrations are named in the review’s Migration name. A draft that removes
something produces two: the removals first, as their own _removals
migration, then everything else.
migrations/
0008_add_recipe.sql a new type
0009_update_recipe_removals.sql one field removed …
0010_update_recipe.sql … and another added, in one apply
meta/ drizzle-kit's snapshots and journalThe split exists because drizzle-kit asks whether a dropped-and-added column is a rename, and with no terminal to answer it the run fails. Neither half holds a drop and an add, so neither asks. The SQL is always drizzle-kit’s; the builder never writes SQL of its own.
In production
With NODE_ENV=production the page is a read-only map: every type, its origin
and its fields as the entry editor’s Content form lays them out, with one
notice saying editing is off in production. SCHEMA_BUILDER=true does not change that, and plan and
apply answer 403.
Run the builder on your machine, commit what it wrote, and deploy. The release
step’s orthacms migrate applies the migration like any other.
When something goes wrong
The content model changed since this page loaded
409 schema-builder.stale. The model moved since your tab loaded it: another
apply landed, the server restarted on an edited type file, or a file was
handed over. The only way on is Reload, which loses that draft.
Another apply is running
409 schema-builder.busy. One apply at a time, across processes. A lock whose
process is gone (a crash, a restart mid-apply) is taken over, so this clears
on its own.
The draft changes a hand-written type
422 schema-builder.not-owned. Undo the change, or hand the file
over and reload.
| Code | Status | When |
|---|---|---|
schema-builder.invalid | 422 | A schema rule fails — the editor shows it under the field first |
schema-builder.unconfirmed | 422 | A destructive change was not confirmed on its own |
schema-builder.migration-ambiguous | 422 | drizzle-kit wanted to ask a question anyway |
schema-builder.migration-failed | 500 | drizzle-kit failed or timed out (60 seconds); nothing changed |
schema-builder.publish-failed | 500 | Writing src/content/ failed after the migration committed |
- A failed publish is the one failure after the database moved. The backup
is kept under
.orthacms/apply/<id>/backupand the error names it; putsrc/content/right by hand. - An apply cut off by a restart reads as interrupted. Reload the page: it shows what the model actually is.
- Stuck on “Waiting for the server to restart”? Nothing is watching
src/. Restart the server, and setSCHEMA_BUILDER_RESTART=manual.
What it is not
- Not a form designer. The editor’s tabs are built in — Content, History, plus plugin tabs such as Access — and groups are accordion blocks on Content. A field’s panel previews that one field; the entry editor is the preview of the form.
- Not a grant. Creating a type gives no workspace access to it.
- Not a production tool. By design: a schema change rewrites code and the database of the whole application, so it is made where code is made.
The reasoning is ADR-0020. The plugin’s routes, configuration and permission are on the schema builder plugin.