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.
The cms: block on an entity
Section titled “The cms: block on an entity”entities: - name: blog.post cms: kind: entry space: blog| Key | Values | Meaning |
|---|---|---|
kind | entry, singleton | entry is a collection. singleton is one document per space, for settings and site-wide copy. |
space | a name | The 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.
The cms: block on a field
Section titled “The cms: block on a field”fields: - name: title type: string cms: { localized: true } - name: price type: int cms: { localized: false }| Key | Meaning |
|---|---|
localized: true | The value may differ per locale row. |
localized: false | The 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.
Spaces
Section titled “Spaces”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.
Columns added for you
Section titled “Columns added for you”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.
| Column | Type | Meaning |
|---|---|---|
documentid | ULID | The cross-locale identity. Every locale row of one document shares it. Not the primary key. |
locale | string | The locale this row holds. Defaults to en. |
status | string | draft or published, per locale row. |
publishedon | timestamp | When 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.
Localisation
Section titled “Localisation”Storage is one row per locale, keyed (documentId, locale).
documentid locale title status01J9Y… en Hello world published01J9Y… de Hallo Welt draftThis 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.
Publication
Section titled “Publication”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.
Relations
Section titled “Relations”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: falseThree 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.
Access rules are part of the type
Section titled “Access rules are part of the type”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.
| Static | Dynamic | |
|---|---|---|
| Where | A file in the product’s content/types/ (layer 2) | YAML in database rows (layer 3) |
| Authored by | whoever owns the product | a customer building their own model through the API |
| Changed by | a product release | a 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.
Where a static type lives
Section titled “Where a static type lives”In the product’s content/types/ folder, one file per type, holding the entity and nothing around
it:
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
.yamlfiles are read. A.ymlfile 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,folderandtag.
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.
How a type is addressed
Section titled “How a type is addressed”A type answers to three names:
| Spelling | Example | Used by |
|---|---|---|
| Plural | posts | /api/posts |
| Type name | post | content_type=post |
| Qualified name | blog.post | relations, 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.
File fields
Section titled “File fields”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.