> From the [Strife developer docs](https://strife.app/docs/reference/cli/schemas). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# Schemas

Define content types in code with defineType and fields, and register them with strife.config.ts — an alternative to building templates by hand in Strife Studio.

`defineType` and the `fields.*` builders are a code-first way to define the same templates you could otherwise build in Strife Studio's UI. Import them from `@strifeapp/strife/schema`, which re-exports everything in `@strifeapp/schema`; there is no need to add `@strifeapp/schema` as a dependency of its own (see [CLI](https://strife.app/docs/reference/cli.md)). A type defined with `defineType` compiles to the same underlying template format described in [Defining Templates with JSON](https://strife.app/docs/reference/templates/defining-templates-with-json.md) — `defineType` is a typed, ergonomic authoring layer on top of that format, not a different concept.

## `defineType`

```ts
import { defineType, fields } from '@strifeapp/strife/schema';

export const article = defineType({
  name: 'articles',
  title: 'Article',
  type: 'document',
  fields: {
    heading: fields.text({ label: 'Heading', localizable: true }),
    body: fields.html({ label: 'Body' }),
    image: fields.image({ label: 'Image', width: 1600, height: 900, format: 'webp' }),
  },
});
```

| Option        | Description                                                                                                      |
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
| `name`        | Required. The type's identifier. Drives the default `collection` name (pluralized, e.g. `article` → `Articles`). |
| `title`       | Label shown in Strife Studio. Defaults to a title-cased version of `name`.                                       |
| `type`        | `'document'` (default), `'content'`, or `'file'`. See [Type kinds](https://strife.app/docs/reference/cli/schemas.md#type-kinds) below.                 |
| `fields`      | An object mapping field names to `fields.*` builders.                                                            |
| `collection`  | Override the derived collection name.                                                                            |
| `description` | Shown as a hint to editors in Strife Studio.                                                                     |
| `icon`        | Icon name shown in Strife Studio. See [Icons](https://strife.app/docs/reference/icons.md).                                                      |
| `composable`  | Whether content built from this type can be composed inside other documents. Defaults to `true`.                 |
| `from`        | The document type(s) pages of this type are created under, and/or a specific page by id. See [Placing pages under a parent type](https://strife.app/docs/reference/cli/schemas.md#placing-pages-under-a-parent-type) and [under a specific page](https://strife.app/docs/reference/cli/schemas.md#placing-pages-under-a-specific-page). |
| `baseProperties` | Whether the built-in `displayName` and `slug` have one value per locale: `{ displayName: { localizable: true }, slug: { localizable: true } }`. Leave one out to keep Strife's default: `slug` is localizable on a team with two or more locales, and `displayName` follows each document's stored name — per locale when it already has one per locale, otherwise shared. See [Localizable `displayName` and `slug`](https://strife.app/docs/reference/cli/schemas.md#localizable-displayname-and-slug). |

`defineType` doesn't do anything at runtime beyond returning its input — the value comes from the types it produces for the rest of your code (see [Typegen](https://strife.app/docs/reference/cli/typegen.md)) and from `strife push` compiling it into a real template.

### Type kinds

| `type`     | Has its own URL | Typical use                                                                               |
| ---------- | --------------- | ----------------------------------------------------------------------------------------- |
| `document` | yes             | A standalone page — a blog post, a product, a landing page.                               |
| `content`  | no              | A reusable block composed inside a document or a [Chapters](https://strife.app/docs/reference/fields/chapters.md) field. |
| `file`     | no              | File-shaped content. Wire `templateType: 'ft'`.                                           |

### Singleton documents

`composable: false` is the right way to model a singleton document type — a root `Home`, a global `Navigation` or settings document, anything that should only ever have one instance. It's not just a hint: Strife Studio's "create new document" action filters on `composable`, so a `composable: false` document template doesn't offer a create action once its one document exists.

```ts
export const navigation = defineType({
  name: 'navigation',
  type: 'document',
  composable: false,
  fields: { /* ... */ },
});
```

`disableURL` isn't a `defineType` option — it's derived automatically from `type` (`false` for `document`, always `true` otherwise). This is deliberate: document types are standalone pages and get a URL by default; content and file types aren't pages and don't. There's no case where overriding it makes sense, so it isn't exposed.

### Placing pages under a parent type

Every page has an origin — the parent it sits under in the content tree, which drives its URL, breadcrumbs and where it is listed. `from` tells Strife which document type(s) a page of this type belongs under, so editors don't have to know. Give it a `defineType` name, or several:

```ts
export const blogPost = defineType({
  name: 'blogPost',
  from: 'blog', // or ['blog', 'newsRoom']
  fields: { /* ... */ },
});
```

In Strife Studio this shapes the origin picker, both when composing a page and when changing a page's origin from the content list:

* exactly one page of a `from` type exists — it is preselected;
* several exist — the picker offers only those;
* none exist — the picker behaves as before, with a hint naming the expected parent type.

Composing from a row's Compose menu keeps that row as origin only when it is of a `from` type, and the menu lists only templates allowed under that row. `from` is a Studio-side rule, not a server-side one: existing content is untouched and the API still accepts any origin. It applies in Strife Studio's classic interface today (the new app shell has no origin picker yet) and to the MCP `create_draft` tool, where an omitted `origin` defaults to the one page of an allowed type, several such pages make the tool ask for `origin`, and none falls back to the team's root page. It does nothing for a type whose documents have no URL (`disableURL`, i.e. content and file types), and an empty list means unrestricted. It compiles to the template's `allowedOrigins` (the parent types' normalized names) and needs `@strifeapp/strife` 1.13.0 or later, which brings `@strifeapp/schema` 0.13.0; on an older version `from` is simply not a known option.

#### Placing pages under a specific page

Sometimes the parent is one particular page rather than a kind of page — the blog, which is one of several list pages. Name it by id with `{ id }`, alone or mixed with type names:

```ts
export const blogPost = defineType({
  name: 'blogPost',
  from: { id: process.env.BLOG_PAGE_ID },
  // or: from: ['newsRoom', { id: process.env.BLOG_PAGE_ID }]
  fields: { /* ... */ },
});
```

A page id belongs to one team — the blog page has one id in your development team and another in production — so it is read from the environment rather than written into the schema. Give each team its own line, prefixed with the team's key:

```
# .env.local (or .env) — one line per team
ACME_BLOG_PAGE_ID=f375a22c-3142-4dbf-90ad-00bd9fee9e97
ACMEDEV_BLOG_PAGE_ID=5d0c2b7e-8a41-4f6e-b3d9-7c1e2a9f4b60
```

`strife push` and `strife typegen` load `.env.local` and `.env` from the directory they run in — the project root, where `strife.config.ts` lives — before reading the schemas. In a directory linked to a team (`strife link`), the lines prefixed with that team's key then stand in for the plain name: a push to ACME reads `ACME_BLOG_PAGE_ID` as `BLOG_PAGE_ID`, a push to ACMEDEV reads `ACMEDEV_BLOG_PAGE_ID`, and each team's template carries only its own page. The prefix is the team key that `strife whoami` shows after the team name, upper-cased, with any character other than A–Z, 0–9 and `_` written as `_`. `strife push` shows the mapping on its `Environment:` line — `Environment: .env.local · ACME_BLOG_PAGE_ID → BLOG_PAGE_ID` — or `no ACME_ variables` when there is none for that team, in which case a plain `BLOG_PAGE_ID` from the files goes out.

A plain `BLOG_PAGE_ID` works too. A value already set in the environment wins over everything, which suits a one-off push; then comes the linked team's line, then a plain line in `.env.local`, then one in `.env`. A tool that loads `.env` before the CLI starts (direnv, dotenv-cli, Bun) puts its plain lines in the environment too, so `strife push` warns whenever a value from the environment keeps the team's line from being used. Per-mode files such as `.env.production` are not read: a push builds nothing, so it has no mode — the team it goes to picks the value, through the prefix.

This works with any framework. Only the CLI reads the id: an app that imports the schema file on the server still loads it without the variable, with the id left out (not in a browser bundle, where `process` does not exist). Use `process.env`, not `import.meta.env`, which does not exist when the CLI loads the schema.

To find a page's id, open the page in Strife Studio's classic interface: the id is the last part of the address (`/team/<key>/<id>`). The `find_content` tool in the MCP tools returns it as well.

Studio's origin picker and the MCP `create_draft` tool treat a page named by id the way they treat a type: when it is the only match it is preselected (for `create_draft`, it becomes the origin), and when the schema also names a type, the pages of that type are offered alongside it (`create_draft` then asks which one). When the id names no page in the current team (the variable holds another team's id, or the page was deleted), the picker falls back to the full list and the hint names the id it looked for, and `create_draft` places the draft under the team's root page with a note naming the id.

An unset variable never breaks compilation — the id is simply left out — but `strife push` and `strife typegen validate` refuse to continue when that happens (`blogPost: from.id is undefined — …`, naming the linked team's prefixed form), because the template would otherwise reach the team without its parent rule and nobody would notice; `push --dry-run` prints the compiled output before it refuses. Pass `--allow-missing-ids` to push or validate anyway, with the id left out and the warning kept, for a team where that page deliberately does not exist.

`strife push` needs a `strife login` session, and a CI runner cannot get one today: login happens in a browser, and every refresh replaces the session's refresh token and retires the old one, so a session copied to a runner stops working at its first refresh. So pushes run from a developer's machine. `strife typegen validate` runs anywhere, CI included, but a CI checkout is usually not linked to a team (`strife link` keeps `.strife/` out of git), so no team line applies there unless you commit `.strife/project.json`: set the plain variable in the CI environment, or pass `--allow-missing-ids`.

It compiles to the template's `allowedOriginIds` and needs `@strifeapp/strife` 1.14.0 or later, which brings `@strifeapp/schema` 0.14.0 (update that too if you import from it directly), plus `@strifeapp/cli` 0.1.0-alpha.16 or later for the `.env` loading and the refusal, and 0.1.0-alpha.17 or later for the team lines. With an older `@strifeapp/strife`, a `{ id }` entry fails to compile with a clear error; with an older CLI, no `.env` file is loaded and nothing stops a push whose variable is unset, and 0.1.0-alpha.16 reads only the plain name.

### Fields

`fields.*` builders map to the same editor components documented under [Fields](https://strife.app/docs/reference/fields.md). Every builder has its own page with the full option set and output type under [Field Reference](https://strife.app/docs/reference/field-reference.md) — the table below is a quick index:

| Builder                                                                                                                         | Editor              | Notes                                                                                                                                                                                                      |
| ------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`fields.text(...)`](https://strife.app/docs/reference/field-reference/fields.text.md)                                                                         | Text                |                                                                                                                                                                                                            |
| [`fields.paragraph(...)`](https://strife.app/docs/reference/field-reference/fields.paragraph.md)                                                               | Paragraph           |                                                                                                                                                                                                            |
| [`fields.number(...)`](https://strife.app/docs/reference/field-reference/fields.number.md)                                                                     | Number              |                                                                                                                                                                                                            |
| [`fields.slider(...)`](https://strife.app/docs/reference/field-reference/fields.slider.md)                                                                     | Slider              | `min`/`max`/`step` configure the range (defaults 0–100, step 1).                                                                                                                                           |
| [`fields.html(...)`](https://strife.app/docs/reference/field-reference/fields.html.md)                                                                         | Rich Content        | `marks`/`nodes` control available formatting/content; `nodes` also carries image size/style/quality presets.                                                                                               |
| [`fields.json(...)`](https://strife.app/docs/reference/field-reference/fields-json.md)                                                                         | Rich Content (JSON) | **Deprecated** — never rendered by the edit panel. Stores arbitrary JSON; read type is `unknown`.                                                                                                          |
| [`fields.date(...)`](https://strife.app/docs/reference/field-reference/fields.date.md) / `fields.date({ withTime: true })`                                     | Date / Date Time    |                                                                                                                                                                                                            |
| [`fields.switch(...)`](https://strife.app/docs/reference/field-reference/fields.switch.md) / [`fields.toggle(...)`](https://strife.app/docs/reference/field-reference/fields.toggle.md)       | Checkbox / Toggle   |                                                                                                                                                                                                            |
| [`fields.slug(...)`](https://strife.app/docs/reference/field-reference/fields.slug.md)                                                                         | Slug                |                                                                                                                                                                                                            |
| [`fields.select(...)`](https://strife.app/docs/reference/field-reference/fields.select.md)                                                                     | Combo Box           |                                                                                                                                                                                                            |
| [`fields.multiSelect(...)`](https://strife.app/docs/reference/field-reference/fields.multiselect.md)                                                           | Multi Select        | Requires `options`. Stores an array of selected `value`s — distinct from `select({ multiSelect: true })`, which stores `{ value, name }` objects.                                                          |
| [`fields.labels(...)`](https://strife.app/docs/reference/field-reference/fields.labels.md)                                                                     | Label               | Picks from the team's labels, grouped for browsing — the same picker used for the built-in `labels` base property. No options to configure; adds an *additional*, independently-valued label-style field, distinct from `labels`.                                                          |
| [`fields.radioGroup(...)`](https://strife.app/docs/reference/field-reference/fields.radiogroup.md)                                                             | Radio Group         |                                                                                                                                                                                                            |
| [`fields.image(...)`](https://strife.app/docs/reference/field-reference/fields.image.md) / [`fields.imageGroup(...)`](https://strife.app/docs/reference/field-reference/fields.imagegroup.md) | Image / Image Group | `image` requires `width`/`height`/`format`. `height: 'auto'` gives a width-only image that keeps the source aspect ratio (compiles to the template's numeric `height: 0`). |
| [`fields.file(...)`](https://strife.app/docs/reference/field-reference/fields.file.md)                                                                         | File                | `allowedTypes` restricts uploads (comma-separated extensions/MIME types).                                                                                                                                  |
| [`fields.video(...)`](https://strife.app/docs/reference/field-reference/fields.video.md)                                                                       | Video               | Read type is `unknown` — stored shape isn't finalized yet.                                                                                                                                                 |
| [`fields.assets(...)`](https://strife.app/docs/reference/field-reference/fields.assets.md)                                                                     | Assets              | `accept` restricts to `'image/*' \| 'video/*' \| 'file/*'` (translated to numeric wire codes); `size` caps the count.                                                                                      |
| [`fields.link(...)`](https://strife.app/docs/reference/field-reference/fields.link.md)                                                                         | Link                | `linkOnly` hides the link-text input for href-only links.                                                                                                                                                  |
| [`fields.serp(...)`](https://strife.app/docs/reference/field-reference/fields.serp.md)                                                                         | Serp                | Usually `localizable: true` — title/description are typically per-locale. No `searchable`/`filterable`.                                                                                                    |
| [`fields.reference(...)`](https://strife.app/docs/reference/field-reference/fields.reference.md)                                                               | Related             | `to` sets the allowed target type(s); `multiple` defaults to `false`.                                                                                                                                      |
| [`fields.references(...)`](https://strife.app/docs/reference/field-reference/fields.references.md)                                                             | References          | `allow` sets the allowed target type(s).                                                                                                                                                                   |
| [`fields.collection(...)`](https://strife.app/docs/reference/field-reference/fields.collection.md)                                                             | Collection          | Stores a query definition (`CollectionDefinition`), not resolved documents — your app runs the query at render time. Studio-UI marks this editor obsolete; see the field page before using it in new work. |
| [`fields.repeatable(...)`](https://strife.app/docs/reference/field-reference/fields.repeatable.md)                                                             | Repeatable          | `of` sets the repeated inner editor (`text`, `number`, `date`, `datetime`, `link`). Feature-gated per team.                                                                                                |
| [`fields.multiInput(...)`](https://strife.app/docs/reference/field-reference/fields.multiinput.md)                                                             | Multi Input         | `inputType` sets the repeated primitive input type (defaults to `text`).                                                                                                                                   |
| [`fields.tableInput(...)`](https://strife.app/docs/reference/field-reference/fields.tableinput.md)                                                             | Table Input         | Requires `headers` and `inputTypes` — parallel arrays, one entry per column.                                                                                                                               |
| [`fields.chapters(...)`](https://strife.app/docs/reference/field-reference/fields.chapters.md)                                                                 | Chapters            | `allow` sets the allowed content types.                                                                                                                                                                    |
| [`fields.contentTemplate(...)`](https://strife.app/docs/reference/field-reference/fields.contenttemplate.md)                                                   | Content Template    |                                                                                                                                                                                                            |
| [`fields.tabs([...])`](../field-reference/fields.tabs.md)                                                                       | Tab Group           | Structural — child fields are grouped under tabs but stored flat, same as Strife stores tab data. Takes an array, not an options object.                                                                   |
| [`fields.hint(...)`](https://strife.app/docs/reference/field-reference/fields.hint.md)                                                                         | Hint                | UI-only — stores no data; omitted from the generated content type.                                                                                                                                         |

Each builder accepts at minimum `{ label, description?, localizable? }` plus type-specific options.

> [!NOTE]
> The **Notes** column above only calls out non-obvious or required options — every builder accepts more than what's shown (most also take `description`, `searchable`, `filterable`, `protected`, and `propertyName`, for example). For a builder's complete set of options — full tables, output types, and wire-format notes — open its page under [Field Reference](https://strife.app/docs/reference/field-reference.md). [Fields](https://strife.app/docs/reference/fields.md) documents the same editors from the Strife Studio UI side. Your editor's TypeScript hover/autocomplete on the `fields.*(...)` call reflects the same options straight from the source types, too.

`localizable` is only available on fields where it's meaningful — text, paragraph, number, date, slug, html, image, link, serp, chapters, and contentTemplate. Fields like `switch`, `select`, and `reference` don't expose it: a boolean or a selected option isn't the kind of value that varies by locale.

### Don't shadow the built-in `Content` fields

Every generated type extends a base `Content` shape that Strife already populates automatically:

```ts
type Content = {
  id: string; docId: string; locale: string; displayName: string;
  url: string | null; origin: string | null; collection: string;
  draft: boolean; publishedAt: string | null;
  createdAt: string; changedAt: string;
  dependencies: string[]; labels: string[];
};
```

Two of these are easy to accidentally redefine as your own fields:

* **`publishedAt`** is set automatically when content is published in Strife Studio — it isn't something an editor fills in. A field named `publishedAt` in your own schema shadows the built-in one with a manually-edited value that means something different, and the generated type ends up with two conflicting notions of "when was this published."
* **`archived`** — Strife has its own archive/soft-delete lifecycle for content documents; archived documents are filtered out of the content index entirely. A field literally named `archived` on your schema is just a checkbox that looks like it does the same thing but isn't connected to it.

Avoid field names that collide with `Content`'s own properties (`publishedAt`, `draft`, `createdAt`, `changedAt`, `labels`, `id`, `docId`, `locale`, `displayName`, `url`, `origin`, `collection`, `dependencies`).

#### Localizable `displayName` and `slug`

`displayName` (the document's name) and `slug` (its URL segment) are base properties: every document has them, and they aren't in `fields`. To give either one a value per locale, say so on the type:

```ts
export const article = defineType({
  name: 'articles',
  baseProperties: {
    displayName: { localizable: true },
    slug: { localizable: true },
  },
  fields: { /* … */ },
});
```

A flag you leave out keeps Strife's default, which is how types behaved before `baseProperties` existed: `slug` is localizable when the team has two or more locales. `displayName` follows each document's stored name: a name that already has a value per locale stays per locale, and a single name stays one value shared by every locale (like a field without `localizable`); new documents start with a single name. Set `localizable: false` to turn one off, `true` to turn it on. A localizable base property is edited on each locale the document has; one that isn't keeps a single field in Strife Studio.

What the type says is what `strife push` sets: the compiled template carries exactly the flags you state, and one you leave out — or delete later — goes back to the default. Upgrading `@strifeapp/strife` therefore changes no URLs. Turning a flag off deletes nothing: stored per-locale values read as the default locale's, and come back if you turn it on again. `strife push` warns about every flag it turns off (for `displayName`, only an explicit `false` turns it off), before the confirmation and under `--dry-run`, and after the push the server names the types that still hold per-locale values, with how many documents do — each with the line to add:

```
! articles: slug is no longer localizable — 12 documents still hold per-locale values (now read as the default locale's; nothing was deleted). To make it localizable again, add to defineType: baseProperties: { slug: { localizable: true } }
```

The warning never stops the push. A push from an older `@strifeapp/strife`, which sends no flags, leaves them as they are on the server.

Whatever the flag, readers get strings: the content index, the generated `Content` type (`displayName: string`), the SDK and the MCP tools resolve a localizable value to the requested locale. A locale without its own `displayName` shows the default locale's.

## `strife.config.ts`

The CLI discovers your schema files through a config file at the project root — `strife.config.ts`, or `.tsx` / `.mts` / `.js` / `.mjs`:

```ts
import { defineConfig, glob } from '@strifeapp/strife/schema';

export default defineConfig({
  schema: glob('./schemas/**/*.ts'),
});
```

`glob()` paths resolve relative to the **config file's own directory**, not your current working directory — pass `--config <path>` (see [Commands](https://strife.app/docs/reference/cli/commands.md#strife-push)) to point at a config file elsewhere, for example in a monorepo:

```sh
npx strife push --config packages/cms/strife.config.ts
```

```ts
defineConfig({
  schema: glob('*.ts', { base: './content/types' }),
});
```

Every exported `defineType()` result across the matched files is collected automatically — you don't need to list types individually.

### Localization

`defineConfig` also accepts an optional `localization` block:

```ts
export default defineConfig({
  schema: glob('./schemas/**/*.ts'),
  localization: {
    defaultLocale: 'en',
    locales: ['en', 'sv'],
    fallbackToPrimary: true,
  },
});
```

| Option              | Description                                                                                        |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `defaultLocale`     | Primary locale. Must be one of `locales`.                                                          |
| `locales`           | Non-empty list of configured locale codes, formatted `[a-z]{2}(-[A-Z]{2})?` (`en`, `sv`, `pt-BR`). |
| `fallbackToPrimary` | Optional. Whether a missing localized value falls back to the default locale.                      |

When present, `strife push` replaces the team's entire localization configuration with this block. If you manage locales through Strife Studio already, check what's currently configured before pushing a `localization` block — it's a full replace, not a merge. Single-locale projects can omit `localization` entirely; there's no behavior change either way.

## JavaScript, not just TypeScript

Everything above works identically in plain JavaScript — `defineType` and `fields.*` are ordinary functions; the type inference is authoring-time sugar for TypeScript users, not a requirement for `push` or `typegen` to work. A `strife.config.js` importing `.js` schema files behaves exactly like the TypeScript version, including the generated types.

You don't lose as much editor support as you might expect by staying in `.js`. `@strifeapp/strife/schema` ships type declarations, and editors with a TypeScript language service (VS Code, for example) use them automatically for plain `.js` files that import it — autocomplete on `fields.text(...)` and friends works without any configuration. What you don't get by default is strict type _checking_ (inline errors on mistakes). Add a single `// @ts-check` comment at the top of a `.js` file to turn that on for just that file — full type checking against those types, with zero TypeScript syntax and no build step change.
