Skip to content

Model Kinds ​

Contentrain organizes content into four distinct model kinds, each optimized for a specific content pattern. Every model definition includes a kind field that determines how content is stored, accessed, and localized.

ts
type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary'

Decision Table ​

Use this table to choose the right kind for your content:

QuestionCollectionSingletonDictionaryDocument
Multiple entries?YesNoYes (keys)Yes
Typed fields?YesYesNo (all strings)Yes (frontmatter)
Has ID/slug?Auto hex IDN/ANo (key = identity)Slug (required)
Rich body content?NoNoNoYes (Markdown)
Typical use caseBlog posts, products, team membersSite settings, hero sectioni18n translations, labelsDocumentation, articles
Storage formatJSON object-mapJSON objectJSON flat key-valueMarkdown + frontmatter

Collection ​

Collections store multiple typed entries as a JSON object-map keyed by auto-generated hex IDs.

Storage Format ​

json
{
  "a1b2c3": {
    "title": "Getting Started",
    "slug": "getting-started",
    "category": "tutorial",
    "published": true
  },
  "d4e5f6": {
    "title": "Advanced Usage",
    "slug": "advanced-usage",
    "category": "guide",
    "published": false
  }
}

Key Characteristics ​

  • Object-map storage — entries keyed by ID, not an array
  • Auto-generated hex IDs — 12 lowercase hex characters when generated; hand-written IDs may be any 1-40 alphanumeric characters with hyphens/underscores
  • Typed fields — each field has a defined FieldType with validation
  • Sorted keys — canonical serialization sorts entry IDs alphabetically
  • Per-locale files — each locale gets its own JSON file

Model Definition Example ​

json
{
  "id": "blog-posts",
  "name": "Blog Posts",
  "kind": "collection",
  "domain": "blog",
  "i18n": true,
  "title_field": "name",
  "fields": {
    "title": { "type": "string", "required": true },
    "slug": { "type": "slug", "required": true, "unique": true },
    "excerpt": { "type": "text", "max": 300 },
    "cover": { "type": "image" },
    "published": { "type": "boolean", "default": false }
  }
}

Output Format (MCP / SDK) ​

When you read a collection via content_list, entries are returned as an array — not the object-map used on disk:

json
[
  {
    "id": "a1b2c3",
    "title": "Getting Started",
    "slug": "getting-started",
    "category": "tutorial",
    "published": true
  },
  {
    "id": "d4e5f6",
    "title": "Advanced Usage",
    "slug": "advanced-usage",
    "category": "guide",
    "published": false
  }
]

Storage vs Output

On disk, collections use object-map format (keys = IDs) for git-friendly merges. MCP and SDK convert this to an array with id injected into each entry. This is intentional — storage is optimized for git, output is optimized for consumers.

Path Patterns ​

ScenarioPath
i18n: true.contentrain/content/{domain}/{model-id}/{locale}.json
i18n: false.contentrain/content/{domain}/{model-id}/data.json
Meta (i18n: true).contentrain/meta/{model-id}/{locale}.json

MCP content_save Call ​

json
{
  "model": "blog-posts",
  "entries": [
    {
      "locale": "en",
      "data": {
        "title": "My First Post",
        "slug": "my-first-post",
        "excerpt": "An introduction to Contentrain.",
        "published": true
      }
    }
  ]
}

TIP

The id field is auto-generated when omitted. You can provide your own ID if needed, but it must be unique within the collection.

Singleton ​

Singletons store a single entry per locale — ideal for one-off content like site settings or hero sections.

Storage Format ​

json
{
  "site_name": "My Website",
  "tagline": "Build faster with AI",
  "logo": "/images/logo.svg",
  "social_links": ["https://twitter.com/example"]
}

Key Characteristics ​

  • Single object — no ID, no slug, just field key-value pairs
  • Typed fields — same field system as collections
  • One file per locale — en.json, tr.json, etc.
  • No entry multiplicity — exactly one entry per model per locale

Model Definition Example ​

json
{
  "id": "site-settings",
  "name": "Site Settings",
  "kind": "singleton",
  "domain": "system",
  "i18n": true,
  "title_field": "title",
  "fields": {
    "site_name": { "type": "string", "required": true },
    "tagline": { "type": "string" },
    "logo": { "type": "image" },
    "social_links": { "type": "array", "items": "string" }
  }
}

Output Format (MCP / SDK) ​

Singletons are returned as a plain object — the same shape as on disk:

json
{
  "site_name": "My Website",
  "tagline": "Build faster with AI",
  "logo": "/images/logo.svg",
  "social_links": ["https://twitter.com/example"]
}

MCP content_save Call ​

json
{
  "model": "site-settings",
  "entries": [
    {
      "locale": "en",
      "data": {
        "site_name": "My Website",
        "tagline": "Build faster with AI"
      }
    }
  ]
}

Dictionary ​

Dictionaries store flat key-value string maps — the primary kind for i18n translations and UI labels.

Storage Format ​

json
{
  "auth.login.title": "Sign In",
  "auth.login.submit": "Log In",
  "auth.login.forgot": "Forgot your password?",
  "common.save": "Save",
  "common.cancel": "Cancel",
  "greeting": "Hello, {name}!"
}

Key Characteristics ​

  • Flat key-value — all values are strings, no nested objects
  • No fields definition — dictionaries do not use the fields property
  • No ID, no slug — the key itself is the identity (semantic address)
  • Semantic keys — use dot notation for namespacing (e.g., auth.login.title)
  • Parameterized templates — values can include {param} placeholders
  • All values must be strings — numbers, booleans, etc. must be string-encoded

WARNING

Dictionary models must NOT have fields defined. The keys in the data object are the content identifiers. Do not pass id or slug — they are ignored.

Model Definition Example ​

json
{
  "id": "ui-labels",
  "name": "UI Labels",
  "kind": "dictionary",
  "domain": "system",
  "i18n": true,
  "title_field": "key",
  "content_path": "locales"
}

Output Format (MCP / SDK) ​

Dictionaries are returned as a flat key-value object — same shape as on disk:

json
{
  "auth.login.title": "Sign In",
  "auth.login.submit": "Log In",
  "auth.login.forgot": "Forgot your password?",
  "common.save": "Save",
  "common.cancel": "Cancel"
}

MCP content_save Call ​

json
{
  "model": "ui-labels",
  "entries": [
    {
      "locale": "en",
      "data": {
        "auth.login.title": "Sign In",
        "auth.login.submit": "Log In",
        "common.save": "Save"
      }
    },
    {
      "locale": "tr",
      "data": {
        "auth.login.title": "Giriş Yap",
        "auth.login.submit": "Oturum Aç",
        "common.save": "Kaydet"
      }
    }
  ]
}

Document ​

Documents store Markdown files with frontmatter — ideal for long-form content like documentation, articles, and blog posts with rich body text.

Storage Format ​

markdown
---
title: Getting Started
description: Learn how to set up Contentrain in your project
order: 1
---

# Getting Started

Welcome to Contentrain. This guide walks you through...

Key Characteristics ​

  • Markdown + frontmatter — structured metadata above, rich content below
  • Slug-based — each document has a required slug that determines the file path
  • body key — the markdown content below the frontmatter delimiter
  • Frontmatter delimiters — --- on its own line
  • Auto-parsed values — true/false become booleans, integers become numbers
  • Typed fields — frontmatter fields are defined in the model schema

Model Definition Example ​

json
{
  "id": "docs-guide",
  "name": "Documentation Guides",
  "kind": "document",
  "domain": "docs",
  "i18n": false,
  "title_field": "title",
  "content_path": "docs/guides",
  "fields": {
    "title": { "type": "string", "required": true },
    "description": { "type": "text" },
    "order": { "type": "integer" }
  }
}

Output Format (MCP / SDK) ​

When you read documents via content_list, each entry is returned as a DocumentEntry:

ts
interface DocumentEntry {
  slug: string
  frontmatter: Record<string, unknown>
  body: string
}
json
[
  {
    "slug": "getting-started",
    "frontmatter": {
      "title": "Getting Started",
      "description": "Learn how to set up Contentrain in your project",
      "order": 1
    },
    "body": "# Getting Started\n\nWelcome to Contentrain. This guide walks you through..."
  }
]

INFO

The frontmatter object contains all fields defined in the model schema. The body field contains everything below the --- delimiter. When listing documents without requesting full content, body may be empty.

Path Patterns ​

ScenarioPath
i18n: true.contentrain/content/{domain}/{slug}/{locale}.md
i18n: false.contentrain/content/{domain}/{slug}.md
Meta (i18n: true).contentrain/meta/{model-id}/{slug}/{locale}.json
Meta (i18n: false).contentrain/meta/{model-id}/{slug}/{default-locale}.json

WARNING

Document meta files have a different path structure than other kinds. They include the {slug} segment: .contentrain/meta/{model-id}/{slug}/{locale}.json. Other kinds use .contentrain/meta/{model-id}/{locale}.json directly. On i18n: false models (any kind) the single meta record is pinned to the default locale — the filename is still {default-locale}.json, never data.json or a flat {slug}.json.

MCP content_save Call ​

json
{
  "model": "docs-guide",
  "entries": [
    {
      "slug": "getting-started",
      "data": {
        "title": "Getting Started",
        "description": "Learn how to set up Contentrain",
        "order": 1,
        "body": "# Getting Started\n\nWelcome to Contentrain..."
      }
    }
  ]
}

TIP

The slug field is required for documents. It determines the file path and URL. The body key inside data is reserved for the markdown content.

Locale Strategies ​

Each model can specify how localized content files are organized using the locale_strategy field:

ts
type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none'
StrategyJSON PatternMarkdown PatternBest For
file (default){dir}/{locale}.json{dir}/{slug}/{locale}.mdMost projects
suffix{dir}/{model}.{locale}.json{dir}/{slug}.{locale}.mdNuxt Content style
directory{dir}/{locale}/{model}.json{dir}/{locale}/{slug}.mdDirectory-first projects
none{dir}/{model}.json{dir}/{slug}.mdSingle-language projects

When i18n is Disabled ​

When a model has i18n: false, locale is ignored entirely:

  • JSON models: stored as {dir}/data.json
  • Document models: stored as {dir}/{slug}.md

Locale Coverage ​

An i18n: true model is expected to carry content in every locale in config.locales.supported. That is what contentrain validate checks parity against, and on a fully-translated site it is exactly right.

Some sites are not fully translated. A blog in three languages next to a set of pages that only ever existed in one is not a defect — but without a way to say so, the pages model fails on every locale it has no file for, and the only ways to clear it are to invent empty translations or to drop the locale from the whole project.

The optional locales field says which project locales a model actually covers:

json
{
  "id": "pages",
  "name": "Pages",
  "kind": "collection",
  "domain": "site",
  "i18n": true,
  "locales": ["en", "da"],
  "title_field": "title"
}
Meaning
Absent (the default)The model covers every locale in config.locales.supported — the behaviour every model had before this field existed
PresentParity is checked against these locales only. The rest are neither required nor reported

Rules:

  • locales must be a subset of config.locales.supported. A locale the project does not support is a validation error — add it to config.json first, or drop it here.
  • It narrows validation; it never grants a locale. Writing content is still governed by config.locales.supported.
  • It has no effect on an i18n: false model, which stores one locale-agnostic copy. Declaring it there is a warning.
  • Inside the subset, the ordinary rules still apply: a locale a model claims but has no file for is still an error, and an entry one of its locales lacks is still reported.

Every locale-coverage message names the list it was evaluated against, so the two cases read differently:

Locale file missing: tr.json (checked against the model's own locales [en, tr])
Entry parity: entry "a1b2c3" exists in en but missing in tr (checked against the project's supported locales [en, tr, da])

WARNING

contentrain validate --fix never invents a locales value for a model that does not declare one. Choosing a narrower set would be deciding which translations are supposed to exist — a content decision — and the one value the tool could compute ("the locales that happen to have files today") would write the current gaps into the schema and silence the very errors that reveal them. Set it deliberately, with contentrain_model_save.

Severity separates a translation that was never written from a locale that is not there at all:

FindingSeverity
An entry of a collection, or a document, that one locale has and another lacks (Entry parity, Missing translation)warning
A locale with no content file (Locale file missing)error
A dictionary key that one locale has and another lacks (Key parity)error

A partly translated site — the usual state of a WordPress site imported from WPML or Polylang — therefore validates, with one warning per untranslated entry.

@contentrain/wp-import writes this field for you: importing a partially-translated WordPress site sets locales to the languages each post type actually has content in (the default locale always included), and omits it entirely when the type is fully translated.

content_path Override ​

By default, content files are stored inside .contentrain/content/{domain}/{model-id}/. The content_path field overrides this, writing content directly into framework-specific directories at the project root.

How It Works ​

json
{
  "id": "blog-posts",
  "kind": "collection",
  "domain": "blog",
  "i18n": true,
  "title_field": "title",
  "content_path": "content/blog"
}
Without content_pathWith content_path: "content/blog"
Content.contentrain/content/blog/blog-posts/en.jsoncontent/blog/en.json
Meta.contentrain/meta/blog-posts/en.json.contentrain/meta/blog-posts/en.json

WARNING

Meta files always stay in .contentrain/meta/ regardless of content_path. Only content files are redirected. This ensures governance data (status, source, approvals) remains in the Contentrain directory.

content_path + locale_strategy ​

The locale_strategy applies to the overridden path too:

locale_strategycontent_path: localesResult
file (default)locales/{locale}.jsonlocales/en.json, locales/tr.json
suffixlocales/ui-labels.{locale}.jsonlocales/ui-labels.en.json
directorylocales/{locale}/ui-labels.jsonlocales/en/ui-labels.json
nonelocales/ui-labels.jsonSingle file (no locale)

Common content_path Patterns ​

Frameworkcontent_pathKindPurpose
Nuxt Contentcontent/blogdocumentNuxt reads from content/
Astrosrc/content/blogcollectionAstro content collections
Next.jscontent/postscollectionCustom content directory
i18n (vue-i18n, next-intl)localesdictionaryTranslation JSON files
VitePressdocs/guidedocumentDocumentation pages

When to Use content_path ​

  • Use it when your framework expects content in a specific directory (e.g., Nuxt Content reads from content/, i18n libraries read from locales/)
  • Don't use it for content that only Contentrain SDK consumes — the default .contentrain/content/ path works fine and keeps everything centralized

Meta Files ​

Every model has corresponding meta files in .contentrain/meta/{model-id}/ that track lifecycle state:

json
{
  "status": "published",
  "source": "agent",
  "updated_by": "contentrain-mcp",
  "version": "1.0.0",
  "publish_at": "2026-01-15T00:00:00Z",
  "expire_at": "2026-12-31T23:59:59Z"
}
FieldTypeDescription
statusContentStatusdraft | in_review | published | rejected | archived
sourceContentSourceagent | human | import
updated_bystringAuthor identifier
approved_bystring?Approver identifier (optional)
versionstring?Version string (optional)
publish_atstring?ISO 8601 scheduled publish date (optional)
expire_atstring?ISO 8601 scheduled expiry date (optional)

Meta file structure by kind:

KindMeta Structure
CollectionObject-map: { "entry-id": { status, source, ... } }
SingletonSingle object: { status, source, ... }
DocumentSingle object: { status, source, ... }
DictionarySingle object: { status, source, ... }

Collection ID vs Dictionary Key ​

This is a common source of confusion. Here is a detailed comparison:

AspectCollection IDDictionary Key
PurposeUnique entry identifierSemantic content address
FormatAuto-generated hex (a1b2c3)Dot-notation path (auth.login.title)
GenerationAutomatic by MCPManual by agent/human
MutabilityImmutable after creationKey is the identity, can be renamed
Typed fieldsYes — each entry has typed fieldsNo — all values are strings
Nested dataSupports complex field typesFlat key-value only
Use caseStructured entities (posts, products)Translation strings, labels
ID in dataAdded on output ({ id, ...fields })Key IS the data address

Relation System ​

Fields with type relation or relations create references between models.

Relation Types ​

TypeCardinalityStorage ValueExample
relationOne-to-one"author": "a1b2c3"Blog post → Author
relationsOne-to-many"tags": ["t1", "t2"]Blog post → Tags

Reference Keys by Target Kind ​

Target KindReference KeyExample
CollectionEntry id"author": "a1b2c3d4e5f6"
DocumentDocument slug"related_doc": "getting-started"
SingletonNot referenceableSingle instance, no need for reference
DictionaryNot referenceableNo schema, no stable identity

Polymorphic Relations ​

When model is an array, the relation can target multiple model types:

json
{
  "related_content": {
    "type": "relation",
    "model": ["blog-post", "docs-guide", "case-study"]
  }
}

Polymorphic values are stored as { model, ref } objects:

json
{
  "related_content": { "model": "blog-post", "ref": "getting-started" }
}

Self-Referencing ​

A model can reference itself for tree structures:

json
{
  "id": "categories",
  "kind": "collection",
  "title_field": "name",
  "fields": {
    "name": { "type": "string", "required": true },
    "parent": { "type": "relation", "model": "categories" }
  }
}

Relation Validation ​

RuleDescription
Referential integrityReferenced ID/slug must exist in the target model
Locale-agnosticIDs and slugs are the same across all locales
No delete-time guardDeleting a referenced entry is not blocked and emits nothing at delete time; the broken reference surfaces as an error on the next contentrain_validate. (Deleting a whole model that others reference is blocked.)
Array orderingrelations array order is preserved
Min/maxrelations supports min and max element count

Unified Across Surfaces

Model kinds, storage formats, and content paths are the same whether you define them locally with @contentrain/mcp or in Contentrain Studio. Content created in one surface works seamlessly in the other.