Features Apograph CMS on GitHub

Content types

Content plugin @apograph/content-server@apograph/content-admin

Two kinds of content type, the flags that shape their storage, and the table each one generates.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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.

OptionTypeWhat it does
fieldsobjectThe field map. Keys become column names, snake_cased. Required.
labelstringHuman name in the admin. Falls back to the type name.
descriptionstringShown in pickers — the workspace wizard and the admin’s type list.
publishablebooleanAdds status and published_at. Turns on the draft/publish workflow.
paranoidbooleanAdds deleted_at. Deleting an entry tombstones it instead of removing it.
i18nbooleanAdds locale and locale_group_id. One row per language.
pathstringsingle() 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:

ColumnTypeNotes
iduuidPrimary key, defaults to a random uuid.
workspace_iduuidThe owning workspace. Plain uuid, not a foreign key.
created_attimestamptzNot null, defaults to now.
updated_attimestamptzNot 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 migrate

The 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.