Content types & fields

The First content type page is the basic tour: create a type, pick fields, wire up references and localization. This page is the reference: inheritance, interfaces, polymorphic blocks, permissions, and what happens under the schema’s hood.

Inheritance (Extends)

A type can inherit another type’s fields. In the type editor, pick a Parent content type (under Inheritance) — the child receives all of the parent’s fields plus its own. Example: blog-post extends page — it gains title, slug, status, seo, blocks, then adds excerpt, published_at, author, tags.

Inheritance rules:

  • Single and chainable — a type inherits one parent, but the parent may itself inherit: landing-page → page → …. Depth is unbounded.
  • Child overrides parent — if the child declares a field with the same name, it replaces the parent’s in place (order preserved).
  • Field order is stable — parent fields first (in their declared order), then the child’s own.
  • Cycles are rejectedA extends B extends A fails with CIRCULAR_INHERITANCE.
  • Parent must exist — pointing at a non-existent slug returns PARENT_NOT_FOUND.
  • Extends is immutable after creation — the parent is locked in once.
  • A child may have zero own fieldslanding-page extends page and adds nothing: the whole page is composed of blocks.

Inherited fields land not only in the GraphQL object type but also in filters and sorting. blogPosts(where: { status: { eq: "published" } }) works precisely because status is inherited from page.

Interfaces (Node, Timestamped)

Every type automatically implements two GraphQL interfaces:

Interface Fields
Node id: ID!
Timestamped created_at: DateTime, updated_at: DateTime

These fields are added by the server — you don’t declare them in the type’s schema. id is generated on record creation; created_at and updated_at are set on write.

A third interface — Entity (id, slug, name) — exists but is not auto-added to types; it’s used for internal typing.

Polymorphic blocks

A Content type field + Make array with no target type is a polymorphic block list. The blocks field on page works this way: one page can hold blocks of any type (Hero, Text, Gallery, CTA, etc.), and element order is preserved.

In GraphQL such a field gets the type [Block], where Block is a union of every type in the schema. Query:

query {
  pages {
    title
    blocks {
      ... on HeroSection { title subtitle }
      ... on TextBlock { body }
      ... on Gallery { images { url } }
    }
  }
}

Polymorphic-block constraints:

  • No filtering or sorting — the blocks field never appears in <Type>Where or <Type>OrderByField.
  • Cannot be localized — the server rejects it (LOCALIZED_POLYMORPHIC_BLOCKS_UNSUPPORTED). The block list is shared across languages.
  • Field names must be compatible — every type sits in the Block union, so same-named fields across types must agree on their GraphQL type. Else: BLOCK_UNION_FIELD_TYPE_CONFLICT.

The union is live: it contains every type in the schema, so a block type created later is queryable through blocks right away (give the async schema rebuild a second or two). You also don’t have to enumerate fragments by hand — a bare blocks { } selection is auto-expanded by the server into per-type scalar selections with __typename. And if two ALREADY-EXISTING types expose a same-named field with different scalar shapes (say Int vs Float), queries keep working: the server aliases the conflicting leaf per union member. The create-time check above guards NEW definitions; older collisions are tolerated at query time, not broken.

If you need a list of references to one concrete type (e.g. tags), specify the target type — the field becomes a regular reference array that can be filtered and localized.

Select fields

A Select (select) is an enum field: the value is chosen from a fixed list of options. It is exposed as String in GraphQL, but the server enforces membership — you cannot store an arbitrary string.

Options are configured right in the field editor. When the type is set to Select, an Options editor appears. Each row has:

  • Value (stored) — what gets saved to the DB (draft, published).
  • Label (shown) — what the editor sees in the admin (Draft, Published).

A Default value is also picked — it is pre-filled on new records.

Example: the status field on page stores draft or published. Trying to write archive — the server returns SELECT_VALUE_NOT_ALLOWED.

If a Select has zero options configured, enforcement is disabled (the field behaves as free text). This is for backward compatibility with legacy fields created before this mechanism existed.

Reserved names

A type’s GraphQL name is the PascalCase of its slug (blog-postBlogPost). Names the schema reserves for itself cannot be used:

Node, Entity, Timestamped, EntityDefinition, PropertyDefinition, Condition, ValidationRule, Item, GenericItem, APIKey, APIKeyMutation, Query, Mutation, CreateEntityInput, UpdateEntityInput, PropertyInput, ConditionInput, ValidationRuleInput, AuthPayload.

If the PascalCase of your slug collides with one of these, the server rejects the type.

Per-type permissions

Every type carries a permission matrix — which roles can read, create, update, and delete its records. Defaults:

Role Read Create Update Delete
viewer yes
editor yes yes yes
admin / owner yes yes yes yes

Permissions live on the type’s permissions field and are edited from the “Permissions” page in the admin. See Roles & access for the full guide.

Under the hood

Records are stored as JSONB in the entity_data table. The type schema (the field definitions) lives separately, in entity_definitions. When you change the schema:

  1. The server validates the type (reserved names, inheritance cycles, Block-union conflicts).
  2. Saves the new version to entity_definitions and bumps the schema version.
  3. SchemaManager rebuilds the GraphQL schema from the current definitions.
  4. The new schema takes effect atomically — queries in flight finish on the old one, new queries hit the new one.

Add a field — it’s in the API. Remove it — it’s gone. No data migration: JSON holds the shape, the definition describes it.

What’s next