Config Reference
Complete reference for the cms.config.ts configuration file.
config()
The top-level configuration function:
import { config } from "@cms/config";
export default config({
locales?: string[]; // default: ["en"]
defaultLocale?: string; // default: first locale
cors?: {
origins: string[]; // allowed CORS origins
};
storage?: LocalStorageConfig | S3StorageConfig; // default: { adapter: "local" }
// LocalStorageConfig: { adapter: "local"; path?: string }
// S3StorageConfig: { adapter: "s3"; bucket, region, accessKeyId, secretAccessKey, endpoint? }
entities: EntityDef[]; // your content entities (including globals)
roles: RoleDef[]; // roles and permissions
admin?: AdminConfig; // admin UI configuration
});
Validation
The config builder validates:
- No duplicate entity names
defaultLocaleexists inlocales- Permission keys reference known entities
Built-in entities
These are auto-registered — you don't define them:
_user— email, name, role_image— filename, mimeType, size, width, height, alt, url_video— filename, mimeType, size, width, height, url_file— filename, mimeType, size, url
entity()
import { entity } from "@cms/config";
entity(name: string, {
fields: AnyField[];
title?: string; // human label shown in the admin (sidebar, list header, breadcrumbs)
description?: string; // rendered under the list header
translatable?: boolean; // default: false
global?: boolean; // default: false — singleton mode (see below)
versions?: boolean | { // see /guides/drafts
limit?: number;
};
hooks?: EntityHooks;
admin?: {
summary?: EntityAdminSummary;
list?: EntityAdminList;
preview?: (props: { data: TData }) => React.ReactNode;
bulkActions?: EntityAdminBulkAction[];
};
}): Entity
name is the canonical identifier — it lands in the database, the API, and as the URL slug. title controls the human-readable label shown wherever the admin renders the entity (sidebar item, list page title, breadcrumb). Provide title and description when the bare entity name is too terse or technical for editors.
entity("project-page", {
title: "Project Pages",
description: "Case studies and client work.",
fields: [/* ... */],
});
admin.summary
Controls how documents appear in list views and relation pickers. Declares which fields to fetch and how to render the title, description, and media slots.
const authors = entity("authors", {
admin: {
summary: {
fields: ["name", "bio", "avatar"],
title: ({ data }) => data.name,
description: ({ data }) => data.bio,
media: ({ data }) => data.avatar
? <img src={`/api/assets/${data.avatar}`} alt="" />
: <UserIcon />,
},
},
fields: [
text("name", { required: true }),
image("avatar"),
text("bio"),
],
});
| Property | Type | Description |
| ------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fields | string[] | Field names to fetch for list views. Only these fields (plus system fields like id, createdAt) are loaded — the rest are skipped. Fully typed and autocompleted against the entity's field names. |
| title | string \| (props) => ReactNode | Document title. A string value is used as-is; a callback receives { data } narrowed to the selected fields. |
| description | string \| (props) => ReactNode | Document description. Same format as title. |
| media | string \| (props) => ReactNode | Visual preview. Same format as title. Supports JSX for images, icons, or any React element. |
When summary is omitted, list views still use a partial fetch. The admin only requests system fields by default, and any fields listed in summary.fields are added to that selection.
Each slot (title, description, media) can be either a static string or a callback. When using callbacks, data is typed as Pick<TData, ...> — only the system fields and the fields listed in fields are available, providing both type safety and editor autocomplete.
admin.list
Controls the table columns rendered in the entity list view. When omitted, the admin falls back to a default layout derived from admin.summary.
const posts = entity("posts", {
admin: {
list: {
fields: ["title", "status", "metadata"],
defaultSort: { field: "createdAt", order: "desc" },
columns: [
{
label: "Title",
sortKey: "title",
render: ({ data }) => data.title,
},
{
label: "Status",
sortKey: "status",
render: ({ data }) => <Badge>{data.status}</Badge>,
},
"createdAt",
],
},
},
fields: [/* ... */],
});
| Property | Type | Description |
| ------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| fields | string[] | Field names to fetch for the list view. Same semantics as summary.fields — only these fields (plus system fields) are loaded. |
| columns | (BuiltInColumn \| ColumnDef)[] | Ordered list of columns to render. See column types below. |
| defaultSort | { field: string; order?: "asc" \| "desc" } | Sort applied on first load when no sort param is present in the URL. field is type-safe: constrained to scalar keys, dotted paths, and "createdAt" / "updatedAt". |
Built-in columns
| Value | Description |
| ------------- | ---------------------------------------------------------------------------------------- |
| "createdAt" | Renders the document creation date. Sortable. |
| "updatedAt" | Renders the last-updated date. Sortable. |
| "locales" | Renders locale badges with links to translations. Only visible on translatable entities. |
Custom column definition
{
label: string;
render: (props: { data: TData }) => React.ReactNode;
sortKey?: string; // enables sorting for this column; value is sent to the API as the sort field
}
sortKey is type-safe: accepts scalar keys of TData, dotted paths through object keys (e.g. "metadata.publishedAt"), or the built-in "createdAt" / "updatedAt". Object-typed keys are excluded to prevent sorting on non-scalar values.
admin.bulkActions
An array of custom actions available when documents are selected in the list view. Each action appears as a button in the bulk action bar alongside the built-in Delete action (and Publish / Unpublish when versions: true).
import { StarIcon } from "@phosphor-icons/react";
entity("posts", {
admin: {
bulkActions: [
{
label: "Feature",
icon: StarIcon,
iconOnly: true,
callback: ({ set }) => {
set({ featured: true });
return {
success: (n) => `${n} ${n === 1 ? "post" : "posts"} featured`,
partialSuccess: (ok, fail) => `${ok} featured, ${fail} failed`,
error: "Failed to feature posts",
};
},
},
],
},
fields: [/* ... */],
});
EntityAdminBulkAction
| Property | Type | Description |
| --- | --- | --- |
| label | string | Button label and accessible name. |
| icon | React.ComponentType | Optional icon component. Phosphor icons recommended. |
| iconOnly | boolean | When true and an icon is set, renders the button icon-only with the label as a tooltip. |
| confirmModal | EntityAdminBulkActionConfirmModal | Optional confirmation dialog shown before the action runs. |
| callback | (ctx) => void \| Messages \| Promise<void \| Messages> | The action logic. Must call set() or map(). Return a messages object to customize toasts. |
Callback context
| Helper | Signature | Description |
| --- | --- | --- |
| set | (data: Partial<TData>) => void | Applies a static partial update to all selected documents. |
| map | (fields, fn) => Promise<void> | Fetches fields for each selected document, calls fn(doc) per document, and applies the returned partial update. Use when the new value depends on current document state. |
Callback return — Messages
All keys are optional. Omitting a key falls back to a generic label-based message.
| Key | Type | Description |
| --- | --- | --- |
| success | (count: number) => string | Toast shown when all selected documents updated successfully. |
| partialSuccess | (succeeded: number, failed: number) => string | Toast shown when some documents succeeded and some failed. |
| error | string | Toast shown when all documents failed. |
EntityAdminBulkActionConfirmModal
| Property | Type | Description |
| --- | --- | --- |
| title | string | Modal heading. |
| description | string | Explanatory text shown below the heading. |
| actions.confirm | string | Confirm button label. Default: "Confirm". |
| actions.cancel | string | Cancel button label. Default: "Cancel". |
See Admin Extensibility — Bulk actions for usage examples.
versions
Opt into the draft → publish lifecycle and version history. See the drafts guide for the full lifecycle and API surface.
versions: true // drafts + publish + history, unlimited retention
versions: { limit: 100 } // same, with per-doc retention cap
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| limit | number | unlimited | Max version rows per document. Older rows are pruned automatically on every insert. Pinned rows (current pending draft and published marker) are never pruned. |
publishedAt is always reserved and auto-injected on every entity, regardless of this setting — the schema is unified across versions on/off. With versions on, drafts have publishedAt: NULL until they're published; with versions off, every write stamps publishedAt = now(). Toggling versions is a behavior change, never a DDL migration.
Auto-injected fields
Every entity gets: id, _type, createdAt, updatedAt, publishedAt.
Translatable entities additionally get: locale, translationGroup.
Reserved names
Always reserved: id, _type, createdAt, updatedAt, publishedAt, locale, translationGroup.
Validation
Throws on duplicate field names within the same entity.
Globals (singleton entities)
Globals are single-row entries for site-wide content: settings, navigation, footer, etc. Define them with entity() and global: true — they live in the same entities array as collection entities.
import { entity } from "@cms/config";
const siteSettings = entity("site-settings", {
global: true,
fields: [
text("siteName"),
text("siteDescription"),
relation("logo", { to: media }),
],
});
export default config({
entities: [posts, pages, siteSettings],
// ...
});
- API:
GET /api/:nameandPUT /api/:name— no ID, no list, no delete - Globals with only optional fields are seeded with a null row on first boot. Globals with required fields are not auto-seeded —
GETreturnsnulluntil the firstPUT(upsert) - Admin: appears in a "Globals" sidebar section, opens directly to the edit form (no list view)
- Permissions work the same as regular entities — note that
createanddeletepermissions have no effect for globals beforeDeleteandafterDeletehooks will never run; all other hooks work normally- Auto-injected fields:
id,createdAt,updatedAt— same as collection entities
role()
import { role } from "@cms/config";
// Admin — full access, bypasses all checks
role(name: string, { isAdmin: true }): RoleDef
// Public — unauthenticated access
role(name: string, {
public: true;
permissions: Record<string, EntityPermissions>;
}): RoleDef
// Authenticated role
role(name: string, {
permissions: Record<string, EntityPermissions>;
}): RoleDef
EntityPermissions
{
create?: boolean | { filter?: Record<string, unknown>; fields?: { exclude?: string[] } };
read?: boolean | { filter?: Record<string, unknown>; fields?: { exclude?: string[] } };
update?: boolean | { filter?: Record<string, unknown>; fields?: { exclude?: string[] } };
delete?: boolean | { filter?: Record<string, unknown>; fields?: { exclude?: string[] } };
}
API keys
Static tokens for headless use — static site generators, CI/CD, server-to-server calls.
GET /api/_api-keys — list all keys (prefix, name, role, last used)
POST /api/_api-keys — create key
DELETE /api/_api-keys/:id — revoke key
All endpoints require an admin session.
Create a key
POST /api/_api-keys
Content-Type: application/json
{ "name": "Blog frontend", "role": "public", "expiresAt": "2027-01-01T00:00:00Z" }
Response (201):
{
"data": {
"id": "uuid",
"name": "Blog frontend",
"keyPrefix": "cms_aB3xK9m",
"role": "public",
"key": "cms_aB3xK9mNpQ2rS7vW1yZ..."
}
}
The raw key is shown once on creation and cannot be retrieved again. Revoke and recreate if lost. Keys are SHA-256 hashed in the database.
expiresAt is optional. When set, expired keys are automatically rejected. Omit it for keys that don't expire.
Using a key
GET /api/posts?filter[status]=published
Authorization: Bearer cms_aB3xK9mNpQ2rS7vW1yZ...
The key assumes the role it was created with. All permission rules apply normally.
Keys can also be managed from the admin UI at Settings → API Keys.
AdminConfig
{
logo?: string;
title?: string;
sidebar?: (builders: SidebarBuilders) => SidebarElement[];
dashboard?: React.ComponentType;
}
sidebar
A function that receives builder helpers and returns an array of sidebar elements. When omitted, the default sidebar is generated automatically.
sidebar: ({ group, item, divider, builtins }) => SidebarElement[]
Builders
| Builder | Returns | Description |
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------ |
| item(name, options?) | SidebarItem | Link to an entity or global. Name is auto-resolved. Options: { label?, icon? } |
| group(label, options) | SidebarGroup | Labeled section. Options: { icon?, collapsible?, defaultOpen?, indent?, children } |
| divider() | SidebarDivider | Horizontal separator |
| builtins.DASHBOARD | SidebarItem | Built-in dashboard item |
| builtins.ASSETS | SidebarItem | Built-in assets manager item |
Groups accept any SidebarElement[] as children, so they can be nested.
See Admin Extensibility — Sidebar for usage examples.
Previous
Field Types Reference
Next
CMS CLI