Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

The schema builder

Schema builder plugin @orthacms/schema-builder-server@orthacms/schema-builder-admin

Add types and fields from the admin in development; it writes the code and the migration you commit.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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:

ConditionDefault in a generated app
SCHEMA_BUILDER=truefalse in .env
NODE_ENV is not productionunset
The source tree on disk: the generated src/content/index.ts under SCHEMA_BUILDER_ROOTapps/server, resolved from where npm run dev starts
You hold schema:manageadministrators only
# .env — on your own machine, never on a server
SCHEMA_BUILDER=true

Then npm run dev and open Content Model. Without one of the conditions the page stays read-only and says why:

The page saysFix
This server runs in productionChange the model in development and deploy it
Editing is offSet SCHEMA_BUILDER=true and restart the dev server
This server has no source files to editRun it from its project (npm run dev), or set SCHEMA_BUILDER_ROOT
The content manifest was written by handRun orthacms content sync once so the builder owns src/content/index.ts
Changing it needs the schema:manage permissionAsk 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.

TemplateFields
Articletitle (required), slug (required), body (rich text), cover (media)
Eventtitle (required), starts (date & time, required), ends, place
FAQquestion (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:

ChoiceStored asLike
Many-to-oneA column on this typeMany posts by one author
One-to-oneA unique column on this typeA product and its spec sheet
Many-to-manyA separate join tablePosts 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:

  1. Generate. drizzle-kit writes the migration into migrations/ — when the change needs one; a code-only change skips this and the next step.
  2. Migrate. Every pending content migration runs in one transaction.
  3. Publish. A schema.applied event is recorded, and the new files are written into src/content/ — once, after the migration committed.
  4. 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:

ChangeVerdictShown asMigration
New typesafeSafeyes
Label, description, page path, groups, field order, display optionssafeSafeno
Field added — optional, on a publishable type, or a booleansafeSafeyes
Trash turned onsafeSafeyes
A validation rule changed (min…, pattern, options, …)dataCheck datano
required changed on a non-publishable typedataCheck datayes
Relation target, cardinality, unique, onDelete, inversedataCheck datayes
Field removeddestructiveDeletes datayes
Type removed, unuseddestructiveDeletes datayes
Type removed while granted or referencedblockedNot applied—
Required field added to a non-publishable type that has entriesblockedNot applied—
Field renamedblockedNot applied—
Field retypedblockedNot applied—
Localized or Draft & publish toggled, trash turned offblockedNot 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 toDo instead
Rename a field and keep its valuesEdit 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 typeEdit the type file, generate, and adjust the SQL so existing values convert
Toggle Localized or Draft & publish, or turn the trash offEdit the type file and write the data migration that goes with it
Remove a type in useRemove 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 entriesAdd 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-generated as 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:

PathWritten byNotes
src/content/collections/<name>.tsthe builder, or youOne file per collection. Deleted when you remove a builder-owned type
src/content/pages/<name>.tsthe builder, or youOne file per single
src/content/index.tsthe builder, or orthacms content syncThe content manifest. Never edit it by hand
migrations/, migrations/meta/drizzle-kitYour content migrations, tracked in __drizzle_migrations_content
.orthacms/the builderScratch 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 journal

The 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.

CodeStatusWhen
schema-builder.invalid422A schema rule fails — the editor shows it under the field first
schema-builder.unconfirmed422A destructive change was not confirmed on its own
schema-builder.migration-ambiguous422drizzle-kit wanted to ask a question anyway
schema-builder.migration-failed500drizzle-kit failed or timed out (60 seconds); nothing changed
schema-builder.publish-failed500Writing 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>/backup and the error names it; put src/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 set SCHEMA_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.