Content model
Files, frontmatter, built-in fields, redirects, and what scribe validate checks.
View as MarkdownFiles
One document = one .mdx (or .md) file in the type's folder:
content/
blog/
hello-world.mdx → slug "hello-world"
_redirects.json → redirect rules for this type (optional)
authors/
jane.mdx → slug "jane"
- The file name is the English slug. Slugs are lowercase kebab-case.
- Only English files exist on disk; locale versions live in the SQLite store.
- Files whose name starts with
_or an uppercase letter (e.g.PUBLISHING.md) are ignored, so you can keep notes next to your content.
Frontmatter
Frontmatter is YAML, validated against the type's Zod schema:
---
title: "Hello, world"
description: "A long enough description for the schema."
author: jane
publishedAt: "2026-01-15"
updatedAt: "2026-02-01"
---
Body is MDX.
Schema validation follows your Zod schema: with a plain z.object unknown keys are silently stripped; use .strict() if you want typos in field names to fail validation.
Built-in frontmatter fields
These are available on every content type without declaring them in the schema; they are extracted from the frontmatter before your schema runs, so declaring them in your Zod schema has no effect:
| Field | Type | Description |
|---|---|---|
publishedAt | ISO date | Publication date. |
updatedAt | ISO date | Last significant update. Defaults to publishedAt. Drives sitemap lastModified. |
noindex | boolean | Excluded from the sitemap; expose it as a robots meta tag in your pages. |
canonicalPath | string | Manually override the canonical URL path. |
Locale documents inherit publishedAt, updatedAt, noindex, and canonicalPath from their English parent; translators can't change them.
Every loaded document also carries slug, enSlug (the English parent slug; equal to slug for English documents), locale, frontmatter, and content.
Bodyless types
Reference-only collections (a model or category type whose schema is entirely structural) often have no MDX body at all. Declare body: false on the type to make that explicit:
defineContentType({
id: "model",
contentDir: "models",
schema: modelSchema,
body: false, // entries are frontmatter-only
});
The default is body: true, so this is fully backwards compatible. On a body: false type, any non-whitespace body content is a validation error, the loader skips MDX parsing, and static exports emit no body. A bodyless type with zero translatable fields also drops out of every translation workflow (no missing/stale noise); a bodyless type that still has a translatable field stays in the todos with a frontmatter-only payload. See translation for the translatability rule.
Beyond frontmatter
MDX bodies can do more than plain prose:
- Inline tokens embed links, asset URLs, and untranslatable literals directly in the body, resolved per locale at read time.
- Assets declared with
field.asset()are validated on disk and resolved to public URLs at load time.
Redirects: _redirects.json
Per content-type folder, add an optional _redirects.json file to declare slug migrations and retired documents. Redirects survive after you delete the source MDX; translated source slugs are expanded automatically from SQLite.
{
"redirects": [
{ "from": "hello-world", "toSlug": "hello-scribe" },
{ "from": ["old-a", "old-b"], "toSlug": "hello-scribe" },
{ "from": "moved-post", "toType": "glossary", "toSlug": "virtual-try-on" },
{ "from": "retired-page", "toUrl": "/pricing" },
{ "from": "retired-ext", "toUrl": "https://example.com/app" }
]
}
Three redirect kinds (exactly one target per entry):
| Kind | Fields | Description |
|---|---|---|
| Same-type | toSlug | Target EN slug in the same content type. URL built from this type's path, localized per locale. |
| Cross-type | toType + toSlug | Target EN slug in another routable content type (must have a path). |
| Anywhere | toUrl | Root-relative same-site path or absolute external URL, identical for every locale. |
from: EN slug(s) only. Translated source slugs are resolved from SQLite.- Optional
permanent(defaulttrue).
To retire or rename a document: add an entry to _redirects.json, delete (or rename) the source MDX, then run scribe validate and rebuild. Redirect rules are emitted by buildAllContentRedirects() for your proxy or framework config.
Validation
scribe validate
Checks, per English file: schema parse, built-in field shapes, your crossValidate hook, and MDX compilation of the body; a body that doesn't parse as MDX is a build-blocking error, for English sources and stored translations alike.
Across the project it also checks: relation integrity (dangling required relation = error, dangling optional relation = warning), _redirects.json rules (valid targets, no duplicate sources, no redirect chains), localized-slug suffix rules, declared asset fields (file existence, formats, maxKB), inline body tokens (malformed spans, dangling relations, :href to non-routable types, missing vars keys, missing asset files), body: false types that still carry body content, missing image assets when the assets directory is set, and that the translation store exists.
Exit code is non-zero when any error is found; run it before your build:
{
"scripts": {
"build": "scribe validate && <your framework build>"
}
}