You need Node 22+, npm 10+ and Docker (or any PostgreSQL 16+ you can reach).
1. Create and boot the app
npx create-orthacms-app my-cms --yes --protocols graphql
cd my-cms
docker compose up -d # Postgres, matching the default DATABASE_URL
npm run migrate # orthacms migrate — builds, then applies every plugin's migrations
npm run dev # API on :3000, admin on :4200--yestakes every default.--protocols graphqladds the GraphQL endpoint; leave it off for REST only.- The scaffolder writes
.envwithDATABASE_URL,ORTHACMS_ROOT_ADMIN_EMAILand a generatedORTHACMS_ROOT_ADMIN_PASSWORD. The password is printed once. - Sign in at
http://localhost:4200with that email and password. There is no public sign-up.
# .env, as written by --yes
DATABASE_URL=postgresql://orthacms:orthacms@localhost:5432/my_cms
ORTHACMS_ROOT_ADMIN_EMAIL=admin@example.com
ORTHACMS_ROOT_ADMIN_PASSWORD=…2. Create a workspace
Content and tokens live inside a workspace.
- Workspaces → New workspace. A new app has no content types yet, so there is nothing to pick on the content step — you grant the type from step 3 to this workspace then.
- Open the workspace and copy its id from the address bar —
/workspaces/<workspace-id>/…— or from Settings → Workspace ID.
3. Define a content type
Content types are code: one TypeScript file each under
apps/server/src/content/. The app already has the rest — the manifest, the
drizzle config and a migrations folder. Write the file from the admin, or by
hand.
From the admin
Stop the dev server, set the flag in .env, and start it again:
SCHEMA_BUILDER=true # development only — production ignores it- Open Content Model in the sidebar and choose New content type: a
collection named
post, labelled Blog posts, with Draft & publish on. - Add field three times:
title(Short text, required),slug(Short text, required),body(Rich text). - Review changes, read the SQL under Technical details, keep the suggested migration name, and Apply 1 change. The server restarts on the new code.
- The applied page offers the type to your workspaces — tick yours and Grant to 1 workspace.
Commit what it wrote: src/content/collections/post.ts,
src/content/index.ts and the new files in apps/server/migrations/. See
the schema builder.
By hand
Create apps/server/src/content/collections/post.ts — the file name and the
exported name match:
import { collection, field } from '@orthacms/content-server/define';
export const post = collection('post', {
label: 'Blog posts',
publishable: true, // gives it Draft and Published states
fields: {
title: field.text({ required: true, minLength: 3 }),
slug: field.text({ required: true, admin: { widget: 'slug' } }),
body: field.richtext()
}
});Regenerate the manifest, write the migration, apply it:
npm run content:sync # rewrites src/content/index.ts
npm run generate -- --name=add_post # writes apps/server/migrations/*.sql — read it, commit it
npm run migrateRestart npm run dev. A new type is granted to no workspace: in your
workspace’s Settings, under Content types, choose Add collections
and pick Blog posts.
4. Write and publish
In the workspace sidebar open Blog posts → Add record, fill it in, press Publish.
5. Mint an API token
In the admin: API Tokens → New token, pick the workspace, access Read-only (read scope). Copy the secret — it is shown once.
Or script it with the root admin’s session:
curl -c jar.txt -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"<ORTHACMS_ROOT_ADMIN_PASSWORD>"}' \
http://localhost:3000/api/auth/login
curl -b jar.txt -H 'Content-Type: application/json' \
-d '{"name":"quickstart","workspaceIds":["<workspace-id>"],"scope":"read"}' \
http://localhost:3000/api/api-tokens{
"id": "4f0c…",
"name": "quickstart",
"workspaceIds": ["<workspace-id>"],
"scope": "read",
"lookupPrefix": "orthacms_a1b2c3",
"expiresAt": null,
"secret": "orthacms_a1b2c3…"
}6. Read it back
curl http://localhost:3000/api/v1/content/post \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: <workspace-id>' const response = await fetch('http://localhost:3000/api/v1/content/post', {
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Workspace-Id': process.env.ORTHACMS_WORKSPACE_ID
}
});
const { items, total } = await response.json(); curl http://localhost:3000/api/v1/graphql \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: <workspace-id>' \
-H 'Content-Type: application/json' \
-d '{"query":"{ posts(pageSize: 10) { total items { id title slug publishedAt } } }"}' {
"items": [
{
"id": "9c4b1e77-…",
"createdAt": "2026-09-30T09:14:03.221Z",
"updatedAt": "2026-09-30T09:20:41.010Z",
"status": "published",
"publishedAt": "2026-09-30T09:20:41.010Z",
"values": {
"title": "Hello, world",
"slug": "hello-world",
"body": { "type": "doc", "content": [] }
}
}
],
"total": 1,
"page": 1,
"pageSize": 25
}- Only published entries are served. Unpublish the post and
itemsis empty. X-Workspace-Idcan be left off when the token covers exactly one workspace.- A
readtoken never sees drafts. A Full access token may add?status=draftor?status=any. - GraphiQL runs at
http://localhost:3000/api/v1/graphql/playgroundoutside production.
Where to go next
- Content types — the full modelling vocabulary
- Reading content — filters, sorting, relations, locales
- The plugin catalogue — what else you can register
- Configuration — every environment variable
- Deployment — running it somewhere real