Features Apograph CMS on GitHub

Quick start

From an empty directory to your first published entry, in one sitting.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Ten minutes, assuming you have Node 22 or newer and a PostgreSQL you can reach.

1. Scaffold an app

npx create-apograph-app my-cms

The wizard asks four questions — where uploads go, which model backends the copilot may reach, how people sign in, and which protocols the content API speaks. Every answer has a sensible default, and --yes takes all of them.

cd my-cms

You now have one package.json and four apps: server, admin, and an end-to-end suite for each.

2. Point it at a database

cp .env.example .env

Set two things:

DATABASE_URL=postgresql://apograph:apograph@localhost:5432/my_cms
APOGRAPH_ROOT_ADMIN_EMAIL=you@example.com
APOGRAPH_ROOT_ADMIN_PASSWORD=a-password-you-will-change

The root administrator is provisioned on boot, idempotently. It is how you get your first login — there is no public registration, and no other way in.

No signing secrets to generate

Sessions and one-time tokens are opaque random values checked against a row, not signed blobs. There is nothing to configure and nothing to rotate.

3. Migrate

npx apograph migrate

This builds the server, then applies every plugin’s migrations in plugin order. Run it again after any upgrade.

4. Boot

npx apograph dev

The API comes up on http://localhost:3000 and the admin on http://localhost:4200. Sign in with the root administrator.

You have a working CMS with no content types in it — a Media Library, Members, workspaces, an activity log, and a copilot panel that stays dark until you register a model backend — there is no keyless mode. What you do not have yet is anything to write.

5. Define a content type

Content types are code. Create apps/server/src/content/post.ts:

import { collection, field } from '@apograph/content-server/define';

export const post = collection('post', {
    label: 'Blog posts',
    fields: {
        title: field.text({ required: true, minLength: 3 }),
        slug: field.text({ required: true }),
        body: field.richtext()
    }
});

Register it in apps/server/src/plugins.ts:

import { join } from 'node:path';
import { ContentPlugin } from '@apograph/content-server';
import { post } from './content/post';

const content = ContentPlugin({
    types: [post],
    migrations: {
        dir: () => join(process.cwd(), 'migrations'),
        table: '__drizzle_migrations_content'
    }
});

The migrations descriptor is what makes the tables yours to generate. It is commented out in the template until your first type exists, because there is nothing to generate before then.

6. Generate its table, and migrate again

npx apograph generate --name=add-post
npx apograph migrate

Read the generated SQL before you apply it. It is your migration, in your repository, and it will run against production one day.

7. Write and publish

Restart apograph dev. Blog posts is now in the sidebar. Create a record, fill it in, and publish it.

8. Read it back

Mint a token in the admin’s API Tokens page — read scope is enough — and spend it against the public API:

curl -H "Authorization: Bearer <token>" \
     -H "X-Workspace-Id: <workspace-id>" \
     http://localhost:3000/api/v1/content/post

The workspace id is the uuid in the admin’s address bar whenever you are inside a workspace — /workspaces/<workspace-id>/content/… — and it is shown on the Workspaces page. A token is minted over one or more workspaces; when it covers exactly one, the header can be left off.

What comes back is a page of published entries, each wrapped in the same envelope:

{
    "items": [
        {
            "id": "9c4b1e77-…",
            "createdAt": "2026-09-06T09:14:03.221Z",
            "updatedAt": "2026-09-06T09:20:41.010Z",
            "publishedAt": "2026-09-06T09:20:41.010Z",
            "values": {
                "title": "Hello, world",
                "slug": "hello-world",
                "body": { "type": "doc", "content": [] }
            }
        }
    ],
    "page": 1,
    "pageSize": 25,
    "total": 1,
    "pageCount": 1
}

Only published entries come back. That is the whole point of the public API: it serves what is live, and a draft is not. Unpublish the post and items is empty on the next request.

Where to go next