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

Quick start

From an empty directory to your first API call, in one sitting.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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
  • --yes takes every default. --protocols graphql adds the GraphQL endpoint; leave it off for REST only.
  • The scaffolder writes .env with DATABASE_URL, ORTHACMS_ROOT_ADMIN_EMAIL and a generated ORTHACMS_ROOT_ADMIN_PASSWORD. The password is printed once.
  • Sign in at http://localhost:4200 with 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.

  1. 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.
  2. 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
  1. Open Content Model in the sidebar and choose New content type: a collection named post, labelled Blog posts, with Draft & publish on.
  2. Add field three times: title (Short text, required), slug (Short text, required), body (Rich text).
  3. Review changes, read the SQL under Technical details, keep the suggested migration name, and Apply 1 change. The server restarts on the new code.
  4. 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 migrate

Restart 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

Example language
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 items is empty.
  • X-Workspace-Id can be left off when the token covers exactly one workspace.
  • A read token never sees drafts. A Full access token may add ?status=draft or ?status=any.
  • GraphiQL runs at http://localhost:3000/api/v1/graphql/playground outside production.

Where to go next