Skip to content
Talk to our solutions team

The content model

A content type is a Data entity carrying a cms: block, and an entry is a row in its table. The catalogue of content types is the schema itself, so there is nothing to keep in step and nothing that can disagree.

entities:
- name: blog.post
cms:
kind: entry
space: blog
KeyValuesMeaning
kindentry, singletonentry is a collection. singleton is one document per space, for settings and site-wide copy.
spacea nameThe content silo this type belongs to.

An entity with no cms: block is an ordinary entity that happens to live in the same database. It is not addressable through any content route, so a table such as audit_log does not become readable because a CMS surface exists.

fields:
- name: title
type: string
cms: { localized: true }
- name: price
type: int
cms: { localized: false }
KeyMeaning
localized: trueThe value may differ per locale row.
localized: falseThe value is shared across the document, and a write propagates it to every locale row.

A shared field is the right declaration for a price, an SKU or a media reference, where a value that differed between translations would be a defect no editor asked for.

A space is a content silo inside a tenant, with its own types, its own content and its own delivery keys. It maps to a Contentful space and to a Strapi project.

The entity name carries the space as a dotted prefix, so blog.post and docs.post are separate types that may share a name and differ in shape.

A space is a naming dimension inside the tenant’s namespace, not a PostgreSQL schema of its own. Adding a space costs nothing at the isolation layer, and a tenant with fifty spaces has one namespace to migrate rather than fifty.

Four columns carry the CMS semantics. They are added at engine build, after the product layer and the tenant overlay have folded, so a statically declared type and one authored through the API arrive with exactly the same shape.

ColumnTypeMeaning
documentidULIDThe cross-locale identity. Every locale row of one document shares it. Not the primary key.
localestringThe locale this row holds. Defaults to en.
statusstringdraft or published, per locale row.
publishedontimestampWhen this locale row was published, or null.

A unique index over (documentid, locale) on live rows comes with them.

Only absent columns are added. If you declare locale yourself with a different default or a different length, yours is kept.

The API surfaces present these in each vendor’s spelling: documentId and publishedAt on the Strapi surface, sys.id and sys.publishedAt on the Contentful one.

Storage is one row per locale, keyed (documentId, locale).

documentid locale title status
01J9Y… en Hello world published
01J9Y… de Hallo Welt draft

This makes ?locale=en a single-row read on a plain index, with no join and no JSON extraction, which is the shape the delivery path needs. It also gives per-locale publish state and per-locale versioning, so German can sit in draft while English is live.

Contentful’s locale=* gathers a document’s rows and pivots them into one entry whose localized fields are maps of locale to value. Non-localized fields are rendered as plain values there, since they are the same across every locale by construction.

Publish state is status plus publishedon, per locale row.

  • The Strapi surface reads it directly through ?status=draft|published.
  • The Contentful surface selects on it through the preview view.
  • Delivery defaults to published only. A draft never appears because a parameter was forgotten.

A new entry is created as a draft. "publish": true on a write moves the state; omitting it leaves the state alone.

For editorial workflows richer than draft to published, the entity can carry a stateflow, which is enforced by the engine.

Row history is the Data block’s SCD versioning, unchanged.

Declare the link in the schema’s references: block. Delivery resolves it when a client asks for it with populate or include.

references:
- name: post_author
parent: { entity: blog.author, fields: [id] }
child: { entity: blog.post, fields: [authorid] }
type: manytoone
create: false
- name: author_publisher
parent: { entity: blog.publisher, fields: [id] }
child: { entity: blog.author, fields: [publisherid] }
type: manytoone
create: false

Three things are worth knowing.

Use the object form, not the entity.field shorthand. A CMS entity name carries its space as a dotted prefix, and the shorthand splits on the first dot, so blog.author.id reads blog as the entity name. The object form says which part is which.

create: false declares the link without a database constraint. The planner uses it to resolve the relation. Deleting an author is then an editorial decision about their posts rather than a refusal from the database.

The rendered name comes from the foreign-key column. authorid renders as author, and reviewerid on the same type renders as reviewer, which is what keeps two links to the same target type distinct.

A relation may point at the type it is declared on. docs.page.parentid pointing at docs.page is a valid model, and resolution terminates at the requested depth.

Content authorization is the entity’s own access rules and nothing else.

access:
actions:
read: { language: expr, expression: 'true' }
create: { language: expr, expression: '"editor" in user.roles' }
update: { language: expr, expression: '"editor" in user.roles' }
delete: { language: expr, expression: '"admin" in user.roles' }

The bindings are the platform’s usual user.*, row.*, tenant, entity and action.

The update rule carries a second meaning: it decides who may see drafts of the type. You may preview what you may change. A type with no update rule is a type nobody can preview, including an administrator. See Preview.

Resolving a relation goes through the target type’s own read rule, so populate= never becomes a way to read a type the caller is not entitled to.

Declaring types statically and dynamically

Section titled “Declaring types statically and dynamically”

Both routes produce the same shape, because both fold onto the service base through the same schema layering.

StaticDynamic
WhereA file in the product’s content/types/ (layer 2)YAML in database rows (layer 3)
Authored bywhoever owns the producta customer building their own model through the API
Changed bya product releasea write to the tenant overlay

The layering rules are the platform’s: additive only, a product may override access per tier, a tenant may not, and a collision is ignored loudly rather than silently.

In the product’s content/types/ folder, one file per type, holding the entity and nothing around it:

content/types/blog.post.yaml
entities:
- name: blog.post
cms: { kind: entry, space: blog }
access:
actions:
read: { language: expr, expression: 'true' }
fields: [ … ]
  • No datastore is named. The Data block declares datastores once, in data/datastores/, and the type lands in the one declared for the Content block.
  • A file may hold several types, and a references: document in the folder links types to each other. See Relations.
  • Only .yaml files are read. A .yml file is reported, not loaded.
  • A file that does not build fails the tenant’s engine build, naming the file and the line. A tenant is never served without a type its product declares.
  • A content app’s contenttypes: list declares nothing. It records which types belong to the app. See Operations.
  • content/extend/ is not for content types. It holds extensions to the block’s own entities: asset, folder and tag.

What happens when a dynamic type is authored

Section titled “What happens when a dynamic type is authored”

Two things run at engine build, which is the one moment that knows the fully composed schema.

Convergence. The composed schema is compared against the live database and the additive part is applied: creating a table, adding a nullable column, adding an index. Changes above that risk level are reported for an operator to apply deliberately, so a content-model edit never drops a column of live content because a request happened to rebuild an engine.

Invalidation. Writing to the overlay marks the tenant stale, and the next resolve rebuilds. The authored type is addressable as soon as the engine that knows about it exists.

A tenant whose database cannot take a change keeps serving the shape its database has. Convergence never takes a tenant offline over a migration an operator can run deliberately.

A type answers to three names:

SpellingExampleUsed by
Pluralposts/api/posts
Type namepostcontent_type=post
Qualified nameblog.postrelations, and disambiguation

Strapi’s paths carry no space, since one Strapi project is one space, so a bare plural is looked up across every space the tenant declares. One match is the answer. Several is an ambiguity, and the response names the qualified alternatives rather than picking one.

Contentful’s paths carry the space natively, and it maps straight onto a content space.

A field of type file holds an opaque reference to an asset:

kisai-asset:<store>/<assetID>[?locale=<locale>]

On delivery, the field is rendered as both the reference and a URL:

"hero": { "ref": "kisai-asset:default/01J9X…", "url": "/store/default/asset/id/01J9X…" }

A client that wants to display the asset needs the URL; a client that wants to re-reference it on a write needs the reference. Resolving one still passes the store’s access rules, so the reference is an address rather than a capability.

See Assets.