Fields
Define your content model with typed field helpers.
Fields are the building blocks of entities. Each field helper is a plain factory function that returns a typed field definition. The system infers storage strategy from the field type — you never decide whether something is a column or JSONB.
Field types
text
String values. Maps to a Postgres text column.
import { text } from "@cms/config";
text("title", { required: true })
text("bio")
text("email", { unique: true, required: true })
slug
A shorthand for text fields with slug semantics. Defaults to unique: true.
import { slug } from "@cms/config";
slug("slug", { required: true })
// equivalent to: text("slug", { unique: true, required: true, isSlug: true })
Slugs can encode path hierarchy: /blog/2025/my-post. See Querying for glob-based filtering.
number
Numeric values. Maps to a Postgres integer column.
import { number } from "@cms/config";
number("price")
number("sortOrder", { required: true })
boolean
Boolean values. Maps to a Postgres boolean column.
import { boolean } from "@cms/config";
boolean("featured")
boolean("active", { defaultValue: true })
date
Timestamp values. Maps to Postgres timestamptz. Values are ISO 8601 strings.
import { date } from "@cms/config";
date("publishedAt")
select
Constrained text values from a fixed set of options. Maps to a text column. Options are type-safe via const assertion.
import { select } from "@cms/config";
select("status", { options: ["draft", "published", "archived"] })
select("priority", { options: ["low", "medium", "high"], required: true })
json
Arbitrary JSON. Maps to a Postgres jsonb column. Use when you need unstructured data.
import { json } from "@cms/config";
json("metadata")
For structured nested data, prefer object instead.
relation
Links to other entities. See the Relations guide for details.
import { relation } from "@cms/config";
relation("author", { to: authors })
relation("tags", { to: tags, multiple: true })
relation("link", { to: [pages, posts] }) // polymorphic
Handling multiple relations
To reference multiple items from another collection, use multiple: true on the relation — not array() wrapping a relation:
// Correct — junction table, referential integrity, filtering, resolution
relation("tags", { to: tags, multiple: true })
// Don't do this — stores IDs as JSONB, losing database guarantees
array("tags", { of: relation("value", { to: tags }) })
Wrapping a relation in array() stores the IDs as a JSONB array of strings. You lose referential integrity (deleting a tag leaves orphaned UUIDs), cross-entity filtering (no junction table to join through), and relation resolution.
media
Shorthand for a relation to the built-in _media collection. Use this for file uploads — images, documents, etc.
import { media } from "@cms/config";
media("featuredImage")
media("gallery", { multiple: true })
media("document", { required: true })
Under the hood, media() creates a relation field targeting _media. See the Media guide for upload handling and image transforms.
object
Structured nested data stored as JSONB. Contains its own fields.
import { object, text, number } from "@cms/config";
object("seo", {
fields: [
text("title"),
text("description"),
text("ogImage"),
],
})
Objects can be nested and used as block definitions.
blocks
An array of typed object variants, stored as JSONB. Each block instance includes a _block discriminator.
import { blocks, object, text, relation } from "@cms/config";
const hero = object("hero", {
fields: [
text("heading", { required: true }),
text("subheading"),
relation("image", { to: media }),
],
});
const faqItem = object("faq_item", {
fields: [
text("question", { required: true }),
text("answer", { required: true }),
],
});
const faqs = object("faqs", {
fields: [
blocks("items", { of: [faqItem] }),
],
});
// Use in an entity
blocks("sections", { of: [hero, faqs] })
In the API response, blocks include a _block field:
[
{ "_block": "hero", "heading": "Welcome" },
{ "_block": "faqs", "items": [...] }
]
array
Repeating values stored as JSONB. Use a primitive field for flat arrays, or an object field for arrays of objects.
import { array, object, relation, text } from "@cms/config";
// Array of strings
array("tags", { of: text("value") })
// Array of objects
array("gallery", {
of: object("item", {
fields: [
relation("image", { to: media }),
text("caption"),
text("alt"),
],
}),
})
- Storage:
jsonb - API value: Array of primitives or array of objects, depending on
of - Admin: Add/remove/reorder items with up/down buttons
Layout directives
Layout directives are pure admin UI — no storage, no columns. Mix them into the fields array alongside regular fields.
import { tabs, collapsible, row } from "@cms/config";
entity("pages", {
fields: [
text("title", { required: true }),
slug("slug"),
tabs([
{
label: "Content",
fields: [blocks("sections", { of: [hero, cta] })],
},
{
label: "SEO",
fields: [object("seo", { fields: [text("title"), text("description")] })],
},
]),
collapsible("Advanced", [
boolean("featured"),
date("publishedAt"),
]),
row([
select("status", { options: ["draft", "published"] }),
date("publishedAt"),
]),
],
})
tabs(tabDefs)— tabbed sections. Each tab haslabelandfields.collapsible(label, fields, { defaultOpen? })— collapsible section, open by default.row(fields)— renders fields side by side.
Directives can be nested. They have no storage impact — invisible to migrations and the API.
Common options
All field types share these base options:
{
required?: boolean; // default: false
unique?: boolean; // default: false
hidden?: boolean; // default: false — see note below
defaultValue?: TValue; // initial form value in admin UI (not applied server-side)
validate?: (value: TValue) => boolean | string; // admin-side validation only (not executed server-side)
admin?: {
component?: () => Promise<unknown>; // custom admin UI component
condition?: (data: Record<string, unknown>) => boolean; // show/hide based on form state
};
}
hidden fields are excluded from API responses and from server-side validation schemas. They cannot be set through the API — only through hooks or direct DB access.
condition receives the current form data and returns a boolean. Hidden fields keep their value — hiding is non-destructive. Works with layout directives.
Conditions are serialized via
.toString()and evaluated on the client. The function must be self-contained — closures over external variables won’t work. Only the function body is transmitted.
select("status", { options: ["draft", "published"] }),
date("publishedAt", {
admin: {
condition: (data) => data.status === "published",
},
}),
text("internalNote", {
admin: {
condition: (data) => data.status === "draft",
},
}),
Custom fields
Custom fields are just wrapper functions. No registry, no special API:
const colorPicker = (name: string, opts?: TextOptions) =>
text(name, {
validate: (v) => /^#[0-9a-f]{6}$/i.test(v),
admin: { component: () => import("./ColorPickerField") },
...opts,
});
const richText = (name: string, opts?: JsonOptions) =>
json(name, {
admin: { component: () => import("./RichTextField") },
...opts,
});
// Usage — same as any built-in field
const brand = entity("brand", {
fields: [
text("name", { required: true }),
colorPicker("primaryColor"),
colorPicker("secondaryColor"),
],
});
The migration system sees the base type (text → varchar, json → jsonb). The admin sees the component override.
Storage strategy
Storage is automatic, inferred from the field type:
| Field type | Storage | Postgres |
|---|---|---|
text, slug, select | Column | text |
number | Column | integer |
boolean | Column | boolean |
date | Column | timestamptz |
json | JSONB | jsonb |
object, blocks, array | JSONB | jsonb |
relation (single) | Column | FK (uuid REFERENCES ...) |
relation (multiple) | Junction table | Separate table with sourceId, targetId, position |
Previous
Configuration
Next
Relations