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. Globally unique — enforced by the _slug table's primary key rather than a per-column constraint.


          import { slug } from "@cms/config";

slug("slug", { required: true });
        

Slugs can encode path hierarchy: /blog/2025/my-post. See Querying for glob-based filtering.

number

Numeric values. Maps to a Postgres double precision 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("scheduledFor");
        

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 });
        

For human-friendly labels distinct from the stored value, pass { label, value } objects instead of plain strings. The database stores value; the admin UI shows label. Mix both forms freely in the same list.


          select("region", {
  options: [
    { label: "North America", value: "na" },
    { label: "Europe", value: "eu" },
    { label: "Asia Pacific", value: "apac" },
  ],
});
        

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.

richText

Rich text content stored as Portable Text JSON. The admin provides a WYSIWYG editor.


          import { richText } from "@cms/config";

richText("body");
richText("excerpt", { required: true });
        
  • Storage: jsonb
  • API value: Array of Portable Text blocks
  • Admin: Toolbar-based rich text editor
  • Frontend rendering: Use @portabletext/react or any Portable Text renderer

Customizing the editor

The richText field is fully configurable. You can control which styles, decorators, lists, annotations, blocks, and inline blocks are available.

Styles control block-level formatting (headings, blockquote, etc.). Decorators control inline formatting (bold, italic, etc.). Lists control list types. Pass plain strings to use built-in defaults.


          richText("body", {
  styles: ["h1", "h2", "blockquote"],
  decorators: ["bold", "italic", "code"],
  lists: ["bullet", "number"],
});
        

Annotations

Annotations are inline marks with structured data — like links with a URL, or footnotes with content. Each annotation has a name and a set of fields that the user fills in via a popover when the annotation is applied.


          richText("body", {
  annotations: [
    richText.annotation("link", {
      fields: [
        text("url", { required: true }),
      ],
    }),
    richText.annotation("footnote", {
      fields: [
        text("note", { required: true }),
      ],
    }),
  ],
});
        

Annotation data is stored in the block's markDefs array:


          {
  "_type": "block",
  "markDefs": [
    { "_type": "link", "_key": "abc", "url": "https://example.com" }
  ],
  "children": [{ "_type": "span", "text": "click here", "marks": ["abc"] }]
}
        

Blocks

Blocks are structured objects that live between text blocks — images, embeds, CTAs, etc. Each block has a name and fields.


          richText("body", {
  blocks: [
    richText.block("image", {
      fields: [text("url", { required: true }), text("alt")],
    }),
    richText.block("callout", {
      fields: [
        select("tone", { options: ["info", "warning", "error"] }),
        text("message", { required: true }),
      ],
    }),
  ],
});
        

Block data in the API includes a _type discriminator:


          { "_type": "image", "_key": "xyz", "url": "/photo.jpg", "alt": "A photo" }
        

Inline blocks

Inline blocks are like blocks but rendered inline within text — think inline images, status badges, or mentions. Same API as blocks, created with richText.inlineBlock.


          richText("body", {
  inlineBlocks: [
    richText.inlineBlock("inline-image", {
      fields: [text("url", { required: true })],
    }),
  ],
});
        

Full example


          richText("body", {
  styles: ["h1", "h2", "h3", "blockquote"],
  decorators: ["bold", "italic", "underline", "code"],
  lists: ["bullet", "number"],
  annotations: [
    richText.annotation("link", {
      fields: [text("url", { required: true })],
    }),
  ],
  blocks: [
    richText.block("image", {
      fields: [text("url", { required: true }), text("alt")],
    }),
  ],
  inlineBlocks: [
    richText.inlineBlock("mention", {
      fields: [text("name", { required: true })],
    }),
  ],
});
        

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 entity, use multiple: true on the relation. array() wrapping a relation is rejected at config build:


          // Correct — junction table, referential integrity, filtering, resolution, ordering
relation("tags", { to: tags, multiple: true, min: 1, max: 5 });

// Rejected at config build
array("tags", { of: relation("value", { to: tags }) });
//      ^ throws: "array(...) cannot contain a relation. Use relation(..., { multiple: true })"
        

There is one canonical way to express many-relations in the DSL — relation({ multiple: true }). It backs the relation with a junction table (referential integrity, filtering, resolution, and ordering), and supports min/max for length validation.

image, video, file

Typed shorthands for relations to the three built-in asset entities. Use these for file uploads.


          import { image, video, file } from "@cms/config";

image("featuredImage");
image("gallery", { multiple: true });
video("demoVideo");
file("attachment", { required: true });
file("downloads", { multiple: true });
        

Each helper creates a relation to its corresponding built-in entity (_image, _video, _file). In the admin UI, they render typed upload inputs with preview, browse, and remove controls instead of the generic relation picker.

For fields that accept multiple asset types, use relation() with a polymorphic target:


          relation("media", { to: ["_image", "_video"] })
        

See the Assets guide for upload handling, MIME routing, image transforms, and querying.

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.

union

A single polymorphic object — one of several object shapes, discriminated by a _type key. Use this when a field holds exactly one typed value and you need to know which shape it is.


          import { union, object, text, number } from "@cms/config";

const imageHero = object("image_hero", {
  fields: [text("src", { required: true }), text("alt")],
});

const videoHero = object("video_hero", {
  fields: [text("url", { required: true }), number("duration")],
});

union("hero", { of: [imageHero, videoHero] });
        

In the API response, the value includes a _type discriminator:


          { "_type": "image_hero", "src": "/photo.jpg", "alt": "A photo" }
        
  • Storage: jsonb
  • API value: A single object with _type set to the variant name
  • Admin: A type selector followed by the selected variant's fields

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

Blocks pattern

To model a list of polymorphic sections (the "page builder" pattern), combine array with union:


          import { array, union, object, text, relation } from "@cms/config";

const hero = object("hero", {
  fields: [text("heading", { required: true }), text("subheading")],
});

const callout = object("callout", {
  fields: [text("message", { required: true })],
});

const imageBlock = object("image_block", {
  fields: [relation("image", { to: media }), text("caption")],
});

array("sections", {
  of: union("section", { of: [hero, callout, imageBlock] }),
});
        

Each item in the array is a union value, so every element carries a _type discriminator:


          [
  { "_type": "hero", "heading": "Welcome" },
  { "_type": "callout", "message": "Sign up today" },
  { "_type": "image_block", "caption": "Our team" }
]
        

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: [union("hero", { of: [imageHero, videoHero] })],
      },
      {
        label: "SEO",
        fields: [
          object("seo", { fields: [text("title"), text("description")] }),
        ],
      },
    ]),
    collapsible("Advanced", [boolean("featured"), date("scheduledFor")]),
    row([
      select("status", { options: ["draft", "published"] }),
      date("scheduledFor"),
    ]),
  ],
});
        
  • 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:


          {
  title?: string;           // human-readable label shown in the admin UI instead of the field name
  description?: string;     // helper text rendered below the label in the admin UI
  required?: boolean;       // default: false
  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?: {
    components?: FieldSlotComponents;  // custom admin UI component overrides (root, label, input, error)
    visible?: (ctx: AdminFieldCtx) => boolean;  // conditionally hide the field in the admin form
    readOnly?: (ctx: AdminFieldCtx) => boolean; // conditionally disable the input
  };
}
        

text and select additionally accept unique?: boolean (default: false) to add a per-entity UNIQUE constraint on the column.

Field visibility and access

Three separate concerns, three separate mechanisms — there is no single "hidden" flag:

| Concern | Mechanism | |---|---| | React to form state (sibling fields) — show "scheduledFor" only when "status" is "scheduled" | admin.visible(ctx) / admin.readOnly(ctx) | | React to the current user (role, identity) — hide internal notes from non-editors | admin.visible(ctx) / admin.readOnly(ctx) | | Block at the API boundary — strip fields from requests and responses for a role | permission.fields.exclude | | Server-owned values — compute deterministically in a hook | beforeCreate / beforeUpdate hook + exclude from writeable roles |

admin.visible and admin.readOnly are admin UI only — they do not affect the API. For server-side enforcement, layer them with permissions.

Conditional visibility — admin.visible(ctx)

A function on any field that returns false to hide it. Re-runs on every form change, so visibility tracks the live unsaved row.


          admin.visible: (ctx: { value: unknown; data: Record<string, unknown>; user: CmsUser }) => boolean
        
  • data is the full unsaved entity row — every sibling field is readable here, including ones that are themselves hidden. Hiding does not strip the value; it only suppresses the input. On save, the hidden field's last value is submitted to the API.
  • user is the authenticated admin user, including their role.
  • value is the unsaved value of this field — reach for it only when the condition depends on the field's own contents.

Show a field based on a sibling value:


          boolean("shippingRequired"),
object("shippingAddress", {
  fields: [text("street"), text("city"), text("postalCode")],
  admin: {
    visible: ({ data }) => data.shippingRequired === true,
  },
}),
        

Show a field based on the current user's role:


          text("internalNote", {
  admin: {
    visible: ({ user }) => user.role === "editor",
  },
}),
        

Combine both — a "scheduledFor" date only visible when status is "scheduled":


          select("status", { options: ["draft", "scheduled", "published"] }),
date("scheduledFor", {
  admin: {
    visible: ({ data }) => data.status === "scheduled",
  },
}),
        

Hidden fields are not stripped from the payload. If you need the value to actually be discarded — or the field to be unreadable across the API — pair admin.visible with permission.fields.exclude on the relevant role. See Permissions.

Works inside any layout directive (tabs, collapsible, row).

Conditional disable — admin.readOnly(ctx)

Same { value, data, user } signature. Renders the field but disables the input — useful for fields the current user can read but not edit:


          text("approvedBy", {
  admin: {
    readOnly: ({ user }) => user.role !== "admin",
  },
}),
        

Typing the value argument

value is typed as unknown, not the field's runtime type. When these callbacks run in the form renderer, TypeScript can't carry the field-specific narrowing into the function arguments (a known limitation when iterating over a discriminated union of fields). Narrow inside the function body:


          text("excerpt", {
  admin: {
    visible: ({ value }) => {
      if (typeof value !== "string") return true; // show when empty/null
      return value.length > 0;
    },
  },
}),
        

For most uses you only need data and user — and those carry precise types. The value argument is the unsaved value of this field; reach for it only when the condition depends on its current contents.

visible and readOnly run in the admin SPA (the config is bundled into it at build time). The functions execute as-is — no serialization, closures over module-level imports are fine.

Custom field components

Every field supports per-slot component overrides via admin.components. You can override any combination of four slots: root, label, input, and error. Each custom component receives a Default prop — the built-in component for that slot — so you can wrap or extend the default rendering without replacing it entirely.


          import type { InputSlotProps, LabelSlotProps } from "@cms/types";

text("title", {
  admin: {
    components: {
      // Wrap the default input with a character counter
      input: ({ Default, value, ...props }: InputSlotProps) => (
        <div>
          <Default value={value} {...props} />
          <span>{(value as string)?.length ?? 0} / 100</span>
        </div>
      ),
      // Add a badge next to the default label
      label: ({ Default, ...props }: LabelSlotProps) => (
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <Default {...props} />
          <span>AI</span>
        </div>
      ),
    },
  },
});
        

Slot reference

| Slot | Props | Purpose | | ------------- | ------------------------------------------------------------------------------- | ---------------------------------------------- | | root | Default, field, label, description, input, error | Wraps the entire field | | label | Default, field, htmlFor | The field label | | description | Default, field | Helper text below the label | | input | Default, field, value, onChange, id, disabled, invalid, errors | The input control | | error | Default, error, id | The validation error message |

Each slot is optional. Omitted slots use the built-in default. To fully replace a slot, ignore Default and render your own JSX. To extend it, render <Default {...props} /> and add your customizations around it.

Custom fields as wrapper functions

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: {
      components: {
        input: ({ Default, ...props }) => <ColorPickerInput {...props} />,
      },
    },
    ...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 type | Storage | Postgres | | --------------------------------------- | -------------- | ------------------------------------------------------ | | text, slug, select | Column | text | | number | Column | double precision | | boolean | Column | boolean | | date | Column | timestamptz | | json | JSONB | jsonb | | object, union, array, richText | JSONB | jsonb | | relation (single) | Column | FK (uuid REFERENCES ...) | | relation (multiple) | Junction table | Separate table with sourceId, targetId, position |

Previous

Configuration

Next

Relations