A content type is declared by calling collection() or single() and
registering the result with ContentPlugin. Everything else — the table, the
admin form, the API routes, the GraphQL type — follows from that one
declaration.
// apps/server/src/content/collections/author.ts
import { collection, field } from '@apograph/content-server/define';
export const author = collection('author', {
label: 'Authors',
description: 'People who write things.',
fields: {
name: field.text({ required: true, maxLength: 120 }),
bio: field.text({ admin: { widget: 'textarea' } })
}
});Collections and singles
collection() defines a type with many entries: articles, authors, products.
single() defines a type with one entry: a home page, a site-settings
record.
import { single, field } from '@apograph/content-server/define';
export const site_settings = single('site_settings', {
label: 'Site settings',
path: '/settings',
fields: {
siteName: field.text({ required: true }),
supportEmail: field.text()
}
});The only structural difference is that single() requires a path — the route
the page renders at in your front end — and the admin presents it as one record
to edit rather than a list to browse. The generated table is the same shape; a
single is not enforced to hold exactly one row by a database constraint.
Options
Both builders take the same options, and single() adds path.
| Option | Type | What it does |
|---|---|---|
fields | object | The field map. Keys become column names, snake_cased. Required. |
label | string | Human name in the admin. Falls back to the type name. |
description | string | Shown in pickers — the workspace wizard and the admin’s type list. |
publishable | boolean | Adds status and published_at. Turns on the draft/publish workflow. |
paranoid | boolean | Adds deleted_at. Deleting an entry tombstones it instead of removing it. |
i18n | boolean | Adds locale and locale_group_id. One row per language. |
path | string | single() only. The route this page renders at. |
The three flags are the interesting ones, because each changes the table.
publishable
export const article = collection('article', {
publishable: true,
fields: { title: field.text({ required: true }) }
});Adds a status column (draft or published, defaulting to draft) and a
nullable published_at. A type without the flag has no publish state at
all — every row is simply live.
There is a second, less obvious consequence. On a publishable type, required
means required to publish, not required to save: the column stays nullable
and the rule is enforced at publish time. That is what lets an author save an
incomplete draft. On a non-publishable type the same required: true produces
a genuine NOT NULL column.
Required means two different things
required: true on a publishable type is a publish-time rule enforced by the
validation service. On a non-publishable type it is a database constraint. Same
keyword, two enforcement points, and it is decided by a flag on the type rather
than on the field.
paranoid
Adds a nullable deleted_at. Deleting sets the timestamp; the row stays.
Restoring clears it. A hard delete is a separate, explicitly named operation.
See publishing and deletion.
i18n
Adds locale (text, not null) and locale_group_id (uuid, not null,
defaulting to a fresh uuid), plus a unique index on the pair
(locale_group_id, locale). Each language of an entry is a whole row, and
the rows that are translations of each other share a locale_group_id.
This flag only provides the storage shape. What a locale is — which slugs are allowed, which is the default, how a read is scoped — belongs to the localization plugin. See localization.
Naming rules
A type name must be snake_case: letters, digits and underscores, starting
with a letter. It is the machine name everywhere — in the table name, in API
paths, in permission checks — so it is worth choosing carefully, and it is not
something to rename casually.
Field names are camelCase by convention and are snake_cased to produce column
names, so readingMinutes becomes reading_minutes.
Reserved column names
Nine column names belong to the platform and cannot be used by a field:
id, workspace_id, status, created_at, updated_at, published_at,
deleted_at, locale, locale_group_id
They are reserved unconditionally — including published_at and
deleted_at on a type that sets neither flag — so that these names mean the
same thing on every type in the system. A field that maps onto one of them
fails at define time, as does a pair of fields that snake_case to the same
column.
What gets generated
Every content type produces one table called content_<name>, carrying four
envelope columns before any of your fields:
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key, defaults to a random uuid. |
workspace_id | uuid | The owning workspace. Plain uuid, not a foreign key. |
created_at | timestamptz | Not null, defaults to now. |
updated_at | timestamptz | Not null; refreshed by the service layer on every save. |
Plus, when the matching flag is set: status and published_at; deleted_at;
locale and locale_group_id.
workspace_id has no foreign key because the workspaces table lives in the
identity plugin’s schema, and content-server cannot reference another plugin’s
tables. Workspace isolation is enforced in the application layer on every read
and write instead. The same is true of media asset ids.
A many-relation additionally generates its own join table, named
content_<type>_<field>. See relations.
Registering a type
Declaring a type does nothing on its own. It has to be handed to
ContentPlugin, and its tables have to be visible to drizzle-kit so migrations
can be generated from them. The reference application does both from one file:
// apps/server/src/content/index.ts
import { joinTableOf, type AnyContentType } from '@apograph/content-server/define';
import { article } from './collections/article';
import { author } from './collections/author';
/** Every content type registered with ContentPlugin, in one place. */
export const contentTypes: readonly AnyContentType[] = [author, article];
/* Re-exported so drizzle-kit can diff them into migrations. */
export const authorTable = author.table;
export const articleTable = article.table;
/* One export per many-relation, or its join table is silently missing. */
export const articleTagsJoinTable = joinTableOf(article, 'tags');drizzle-kit only sees top-level table exports
A table that is not re-exported from this file does not appear in the migration
diff, and the type will fail at runtime against a table that was never created.
joinTableOf exists for exactly this: it throws when the named relation is
missing or has been renamed, so a dropped join table fails loudly at build
rather than vanishing quietly from a migration.
Then register the array:
// apps/server/src/plugins.ts
ContentPlugin({ types: contentTypes });Changing a type
Because the type is code, changing it is a code change followed by a migration:
npx apograph generate --name=add_article_subtitle
npx apograph migrateThe generated SQL is committed alongside the change to the collection file, so
the two travel together through review and deploy as one thing. See
migrations — including the nx run form of the
same two commands, which is what a contributor to the monorepo runs.
No runtime schema editing
There is no admin screen that adds a field, and no API that creates a content type. Every model change is a deployment. If a non-engineer needs to add fields on their own, Apograph does not do that and is not going to.