Skip to content
Strife Docs

Reference CLI

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). A type defined with defineType compiles to the same underlying template format described in Defining Templates with JSON — defineType is a typed, ergonomic authoring layer on top of that format, not a different concept.

defineType

TypeScript
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' }),
  },
});
OptionDescription
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 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.
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 and 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.

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) and from strife push compiling it into a real template.

Type kinds

typeHas its own URLTypical 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 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.

TypeScript
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:

TypeScript
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:

TypeScript
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. Every builder has its own page with the full option set and output type under Field Reference — the table below is a quick index:

BuilderEditorNotes
Text
Paragraph
Number
Slider
min/max/step configure the range (defaults 0–100, step 1).
Rich Content
marks/nodes control available formatting/content; nodes also carries image size/style/quality presets.
Rich Content (JSON)
Deprecated — never rendered by the edit panel. Stores arbitrary JSON; read type is unknown.
fields.date(...) / fields.date({ withTime: true })
Date / Date Time
Checkbox / Toggle
Slug
Combo Box
Multi Select
Requires options. Stores an array of selected values — distinct from select({ multiSelect: true }), which stores { value, name } objects.
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.
Radio Group
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).
File
allowedTypes restricts uploads (comma-separated extensions/MIME types).
Video
Read type is unknown — stored shape isn't finalized yet.
Assets
accept restricts to 'image/*' | 'video/*' | 'file/*' (translated to numeric wire codes); size caps the count.
Link
linkOnly hides the link-text input for href-only links.
Serp
Usually localizable: true — title/description are typically per-locale. No searchable/filterable.
Related
to sets the allowed target type(s); multiple defaults to false.
References
allow sets the allowed target type(s).
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.
Repeatable
of sets the repeated inner editor (text, number, date, datetime, link). Feature-gated per team.
Multi Input
inputType sets the repeated primitive input type (defaults to text).
Table Input
Requires headers and inputTypes — parallel arrays, one entry per column.
Chapters
allow sets the allowed content types.
Content Template
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.
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. Fields 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:

TypeScript
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:

TypeScript
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:

TypeScript
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) to point at a config file elsewhere, for example in a monorepo:

Terminal
npx strife push --config packages/cms/strife.config.ts
TypeScript
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:

TypeScript
export default defineConfig({
  schema: glob('./schemas/**/*.ts'),
  localization: {
    defaultLocale: 'en',
    locales: ['en', 'sv'],
    fallbackToPrimary: true,
  },
});
OptionDescription
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.