---
title: Content model
description: >-
  Files, frontmatter, built-in fields, redirects, and what scribe validate
  checks.
order: 3
section: guides
noindex: false
canonicalPath: /docs/content-model
---

## Files

One document = one `.mdx` (or `.md`) file in the type's folder:

```text
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:

```mdx
---
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:

```ts
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](/docs/translation) for the translatability rule.

## Beyond frontmatter

MDX bodies can do more than plain prose:

- [Inline tokens](/docs/inline-tokens) embed links, asset URLs, and untranslatable literals directly in the body, resolved per locale at read time.
- [Assets](/docs/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.

```json
{
  "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` (default `true`).

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

```bash
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](/docs/assets) (file existence, `formats`, `maxKB`), [inline body tokens](/docs/inline-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:

```json
{
  "scripts": {
    "build": "scribe validate && <your framework build>"
  }
}
```
