Content model
How krantho models content — collections with Zod schemas, documents made of fields and a markdown body, sections, globals, drafts and versions, references, media and locales.
Content in krantho is a set of documents grouped in collections. A document has fields — structured values such as a title, a date or a reference — and a body in markdown. Each collection declares its fields with a Zod schema in your code. That one schema validates every write, types your reads, and becomes the form editors fill in /admin.
Collections#
A collection is one kind of document. krantho.com, which runs on krantho, has a collection for its feature pages, one for this documentation, one for release notes and one for standalone pages such as About, among others. Define each with defineCollection from @krantho/schema. Here is krantho.com's own krantho.collections.ts, shortened to two of its collections:
import { join } from 'path'
import { defineCollection } from '@krantho/schema'
import { z } from 'zod'
const dir = (name: string): string => join(process.cwd(), 'content', name)
const status = z.enum(['draft', 'published']).default('published')
const ref = (collection: string) => z.string().describe(`ref:${collection}`)
/** Standalone pages: about, agent skills, legal */
const pages = defineCollection({
name: 'pages',
source: 'filesystem',
directory: dir('pages'),
path: '/:slug',
schema: z.object({
title: z.string(),
description: z.string(),
eyebrow: z.string().optional(),
status,
}),
})
/** Capabilities: cards on the landing page and a page each */
const features = defineCollection({
name: 'features',
source: 'filesystem',
directory: dir('features'),
path: '/features/:slug',
schema: z.object({
title: z.string(),
description: z.string(),
benefit: z.string(),
order: z.number().default(99),
icon: z.enum(['content', 'admin', 'seo', 'cache', 'media', 'database', 'skills', 'config', 'frameworks']),
'proof-sample': z.string().optional(),
'docs-page': ref('docs').optional(),
status,
}),
})
export const collections = { pages, features }nameidentifies the collection in the API, the admin and every read. The key in the exported object must match it.source: 'filesystem'means the collection has seed files, one markdown file per document indirectory.source: 'database'has none: its documents are created in the admin or through the API.schemais a Zod object. Its keys are the field names; kebab-case is the convention.pathis where the collection's documents render — see Lists and details.
Collections are wiring, not configuration: they stay in code, apart from krantho.config.ts. Keep them in one module and pass the same object to the API route and to the content loader your pages read through, so both import and validate the same content.
Fields and body#
In a markdown file, the fields are the YAML frontmatter between the --- markers, and the body is everything below:
---
title: 'Structured SEO'
description: 'Schema.org structured data from archetypes, meta and social tags from your content, and AI enrichment on the LLM provider you choose.'
benefit: 'Every page carries a connected JSON-LD graph without anyone writing JSON-LD by hand.'
order: 3
icon: seo
proof-sample: seo-config
docs-page: seo-pipeline
---
## Problem
Search engines and AI assistants read structured data, not only text.
## Approach
Instead of writing JSON-LD, you choose Schema.org types, called archetypes, at four scopes.Inside krantho — the API, the admin, the loader your pages use — the same values are called fields: fields.title, fields.benefit. Frontmatter is the file's word for them, fields the CMS's.
- Validation. Every create and update through the API is checked against the collection's schema, and a seed file that fails it is skipped at import with a warning naming the field.
- Custom fields. Any key the schema declares is a field. Name fields for what they mean to an editor:
benefit,proof-sample,docs-page. - Field values in the body. An at sign followed by a field name, such as
@version, is replaced with that field's value when the page renders. Text that names no field stays as written. - The SEO description.
meta-descriptionis a separate field fromdescription, and falls back to it when empty. See Content fields for the fields krantho recognizes.
Sections#
## headings divide the body into named sections. Everything under a heading, ### sub-headings included, belongs to it until the next ##:
Opening paragraph, before any heading.
## Problem
What goes wrong without it.
## Approach
How krantho solves it.The text before the first heading is the lead. A ## inside a fenced code block is code, not a heading. Pages receive the sections as a record keyed by heading text, each rendered to HTML: content.sections['Approach']; splitSections() from @krantho/core gives the lead and the markdown of each section instead.
A page template can give particular sections their own design — a section contract. The collection declares these designed sections in order, with sections in defineCollection(), and the template reads them from there; see Content and page templates. krantho.com's features collection declares Problem, Approach, Proof and Limits, each with its own layout. Name sections in kebab-case (how-it-works) when you also want section-level fields such as meta-description-how-it-works; see Content authoring.
Globals#
A collection named globals holds what every page shares. krantho.com has one document per concern — navigation, footer and announcement — each with fields and no body.
Globals are treated differently in a few places:
- They have no page of their own, so they are left out of the sitemap and have no draft preview.
- A change to a global reaches every page that shows it. With the Next.js page cache, those pages are marked stale and refresh as they are visited, not all at once.
- Read them in a layout, not in the root layout of a Next.js app: the build renders the 404 page through the root layout and must not read the database. See Rendering modes.
Lists and details#
Most collections are list→detail: a list page shows every published document, and each document has its own page. features becomes /features and /features/seo. A page that stands alone, such as About, is a document in pages.
The slug is the file name without .md, or the slug an editor gives a document created in the admin. A collection's path in defineCollection() says where its documents render: a pattern such as '/deploy/:slug' (the document index renders at /deploy), a fixed path such as '/' for a landing page kept in its own collection, or false for documents with no page of their own, such as code samples. Without one, pages documents render at the site root (home at /) and every other collection at /<collection>/<slug>. Krantho uses that path wherever it needs a document's page — the sitemap, the draft preview, the re-render right after a publish and the page's JSON-LD — so route your pages to match it.
Drafts, publishing and versions#
Every document has a current version, which the admin edits, and at most one published version, which visitors see. Pages read only the published version.
| Action | What visitors see |
|---|---|
| Save — in the admin, through the API, by the assistant or by enrichment | Nothing changes. The save is a new current version. |
| Publish | The current version goes live. |
| Unpublish, archive or delete | The page goes offline and answers 404. |
- Status. A document is published while a version of it is live, a draft while none is, or archived. While the live version is older than the current one, the document has unpublished changes, and the admin says so. A seed file imports as its
statusfield says; a file without one is published (content.defaultStatus.filesystem). A document created in the admin or through the API is a draft until it is published. - History. On SQLite and Postgres every save, publish and unpublish adds a version and none is ever changed. Restoring an old version creates a new current version from it and leaves the live one as it is, and a delete is recorded as a version too. The editor shows the history with a diff view and a restore button.
- Preview. Editors see unpublished changes on the page itself, in Live mode or with Open on site, while visitors keep the published page. See Admin dashboard.
- Locks. While one editor holds a document's editing lock, nobody else can save, publish or delete it until the lock is released or expires.
The filesystem provider keeps no history, so a save to a published file is live at once. See Persistence.
References#
A field that points at another document is a reference. Describe a string field with ref: and the target collection:
const ref = (collection: string) => z.string().describe(`ref:${collection}`)
const features = defineCollection({
name: 'features',
// …
schema: z.object({
title: z.string(),
// A picker listing the docs collection's documents by title
'docs-page': ref('docs').optional(),
}),
})The admin renders the field as a picker over the target collection, and stores the chosen document's slug. The loader does not follow references: the page template reads the referenced document with a second read, getCMSContent('docs', fields['docs-page']) — that is how a feature page on krantho.com links to its docs page. A list of slugs, z.array(z.string()), is edited as a tag input, and the template reads each one; these docs pages list their next and related pages that way.
Media#
Describe a string field with media to make it a media picker:
'hero-image': z.string().describe('media').optional(),The field stores the media item's id. Render it through the image endpoint, which serves resized variants and follows a replaced file:
<img src={`/api/media/${fields['hero-image']}/image?preset=lg`} alt="" />The media library is on when media.provider names a file store. See Media for the stores, presets and image editing.
How fields become the admin form#
The admin reads each collection's schema and picks a control per field:
| In the schema | In the admin |
|---|---|
z.string() | A text input; a textarea when the name contains description, ingress or intro, a color picker when it contains color |
z.enum([...]) | A select |
z.number() | A number input |
z.boolean() | A checkbox |
z.array(z.string()) | A tag input |
z.string().describe('media') | A media picker |
z.string().describe('ref:<collection>') | A reference picker |
Fields you add in the admin's schema editor are runtime fields: string, textarea, number, boolean, date, enum, url, email, color or reference. They are additive only — the schema in your code always wins, and a runtime field can never change a field the code defines. When a runtime field has proven itself, the schema editor exports the merged schema as Zod code for you to adopt.
Locales#
A site lists its locales in configuration: site.supportedLocales, and site.defaultLocale for requests that name none. Both default to site.language. The default locale is the site's only default: a document, a markdown file or an API request that names no locale is in it, and it is set only in the configuration.
import { defineKranthoConfig } from '@krantho/config'
export default defineKranthoConfig({
site: {
domain: 'example.com',
name: 'Example',
language: 'en',
baseUrl: 'https://example.com',
supportedLocales: ['en', 'nb'],
},
})- Each document keeps a version history per locale. The editor's locale chips move between them, and Setup › Locales in Studio, when the site has more than one locale, shows each collection's translation coverage and lets an admin choose which of the configured locales are active.
- Reads take an optional locale and default to the site's default locale:
getCMSContent('pages', 'about', { locale: 'nb' }). The content API filters by?locale=.
Next steps#
- Content and page templates — turn collections into page types and routes.
- Content authoring — how editors and seed files write content.
- Content fields — the fields krantho recognizes.
- @krantho/schema — the schema API.
Questions and answers
Where does my content live?
In your database, SQLite or Postgres, reached through one adapter interface, IPersistenceAdapter. Every save appends a version, so the history stays with the content. Media metadata is stored in the same database, and the files in the store you choose: local disk, Vercel Blob, an S3-compatible bucket or Google Cloud Storage.
Markdown files in your repository seed an empty database on first boot. After that, the database is where editors work.
Content leaves your infrastructure only if you turn on AI enrichment: the content being enriched is then sent to the LLM provider you configured.
Can I change the content model after launch?
Yes. Collections are Zod schemas in your code, so you change them like any other code and deploy.
Between deploys, the admin's schema editor can add runtime fields to a collection. They only add keys: a field defined in code cannot be changed or removed from the admin, and code always wins. The admin exports the merged schema as Zod code, so a runtime field that proves useful can move into the codebase.