Ten minutes, assuming you have Node 22 or newer and a PostgreSQL you can reach.
1. Scaffold an app
npx create-apograph-app my-cmsThe 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-cmsYou 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 .envSet 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-changeThe 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 migrateThis builds the server, then applies every plugin’s migrations in plugin order. Run it again after any upgrade.
4. Boot
npx apograph devThe 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 migrateRead 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/postThe 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
- Content types — the modelling vocabulary in full
- The plugin catalogue — what else you can register
- Reacting to changes — a webhook receiver that rebuilds when something publishes
- Configuration — every environment variable
- Deployment — running it somewhere real