Browse documentation

Content modelling

Fields

All 15 field kinds, their options, and how composite fields nest.

Fields are created with defineField.<kind>(options). Every kind accepts these base options:

Option Type Meaning
label string Admin UI label. Defaults to a humanised field name
required boolean Enforced on every write by validateCollection
defaultValue unknown Applied on create when the incoming value is undefined
unique boolean Single-field unique index. For a constraint spanning multiple fields, see Collections § Indexes
index boolean Creates an index on the column
access FieldAccess Per-field read/write rules — see Access control
hooks FieldHooks beforeValidate / beforeChange / afterRead — see Hooks

Scalar kinds

text

defineField.text({ required: true, minLength: 3, maxLength: 120 });

textarea

Same options as text; the admin renders a multi-line input.

number

defineField.number({ min: 0, max: 5 });

boolean

defineField.boolean({ defaultValue: false });

date

defineField.date({ withTime: true });

A date is an ISO-8601 string in toISOString() form everywhere: in the database, on Local API reads and in HTTP responses ("2026-01-15T10:30:00.000Z"). Writes accept a Date or any string Date can parse (a numeric timestamp too, though the typed input only advertises Date | string); Forge stores the canonical string, so every adapter returns the same value. Parse it with new Date(value) when you need a Date.

email

text plus format validation.

select

defineField.select({ options: ['draft', 'review', 'published'], defaultValue: 'draft' });

Values outside options fail validation. The admin renders a native select.

slug

defineField.slug({ autoGenerate: true, sourceField: 'title' });
  • A slug you supply is normalised ("Láser & Piel" → laser-piel) rather than rejected.
  • With autoGenerate: true and no value, it is derived from sourceField — or from title/name if you do not name one.
  • On update, an existing non-empty slug is kept: renaming a title does not silently break a URL. Clear the field explicitly to regenerate it.
  • The same slugify() is exported from @forge-cms/core, so client-side previews match the server.

json

An escape hatch: any JSON-serialisable value, stored in a TEXT column, validated only as "serialisable". Prefer group/array when the shape is known — you get validation and a real admin form.

richtext

defineField.richtext();

The value is RichTextContent — an array of nodes, each with a type, optional text, optional children, and open-ended extra keys for marks and node data. The format is deliberately close to what Slate/Lexical-style editors produce.

const body: RichTextContent = [
  {
    type: 'paragraph',
    children: [
      { type: 'text', text: 'Hello ' },
      { type: 'text', text: 'world', bold: true }
    ]
  }
];

The admin ships a block-level editor (ForgeRichTextEditorComponent) with a JSON fallback — not a full WYSIWYG.

relation

defineField.relation({ collection: 'categories' }); // one id
defineField.relation({ collection: 'tags', many: true }); // an array of ids

Stored as ids. Ask for depth: 1 on a read and they are replaced with the referenced documents, using one batched query per relation field — not one per document. Written ids must exist, and onDelete (restrict by default, cascade, set-null) is enforced atomically — see Relation integrity. Only top-level, non-localized relations are supported; one inside a group/array/blocks is refused at startup.

upload

defineField.upload({ collection: 'media' });

Structurally a single relation pointing at an upload-enabled collection, but it tells the admin to render a media picker and the client to expect file metadata. See Uploads. Like a relation it must name an existing document, and it always restricts: a media document cannot be deleted while an upload field still points at it.

Composite kinds

Composite values are stored as JSON in a single TEXT column and validated recursively. They cannot be queried into — filtering happens on scalar columns only.

group

A fixed set of nested fields, stored as one object:

address: defineField.group({
  fields: {
    street: defineField.text({ required: true }),
    city: defineField.text(),
    postcode: defineField.text()
  }
});
// → { street: string; city: string; postcode: string }

array

A repeatable list of rows that all share one shape:

faqs: defineField.array({
  minRows: 1,
  maxRows: 10,
  fields: {
    question: defineField.text({ required: true }),
    answer: defineField.textarea({ required: true })
  }
});
// → { question: string; answer: string }[]

blocks

The page-builder primitive: a repeatable list where each row picks one of several shapes, discriminated by blockType.

import { defineBlock, defineField } from '@forge-cms/core';

const hero = defineBlock({
  slug: 'hero',
  label: 'Hero',
  fields: {
    headline: defineField.text({ required: true }),
    image: defineField.upload({ collection: 'media' })
  }
});

const richText = defineBlock({
  slug: 'richText',
  fields: { body: defineField.richtext() }
});

const pages = defineCollection({
  slug: 'pages',
  fields: {
    title: defineField.text({ required: true }),
    layout: defineField.blocks({ blocks: [hero, richText], minRows: 1 })
  }
});

Rows come back as BlockValue — Record<string, unknown> & { blockType: string }. They are deliberately not a discriminated union: narrowing on blockType is a consumer-side concern, and a precise union makes the recursive field types unresolvable.

for (const block of page.layout as BlockValue[]) {
  if (block.blockType === 'hero') {
    // narrow it yourself, e.g. with a type guard per block
  }
}

Nesting is arbitrary — a group inside an array inside a blocks row validates, and the admin's ForgeFieldControlComponent recurses into itself to render it.

Validation

Every write runs validateCollection, which returns errors shaped like:

{ field: 'title', message: 'title is required', code: 'required' }

Codes are required or type_<kind> (type_text, type_number, type_relation, …). Over HTTP they arrive as 400 { error, details: ValidationError[] }; through the Local API they are thrown as ValidationFailedError carrying the same details.

Partial updates validate what you send: a required field already present on the stored record does not have to be repeated in a PUT body.