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 has label and fields.
  • 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 (textvarchar, jsonjsonb). The admin sees the component override.

Storage strategy

Storage is automatic, inferred from the field type:

Field typeStoragePostgres
text, slug, selectColumntext
numberColumninteger
booleanColumnboolean
dateColumntimestamptz
jsonJSONBjsonb
object, blocks, arrayJSONBjsonb
relation (single)ColumnFK (uuid REFERENCES ...)
relation (multiple)Junction tableSeparate table with sourceId, targetId, position

Previous

Configuration

Next

Relations