Before you can add content to a StaticOwl site, you tell it what your content looks like — a content type. A content type is a named schema: a set of fields (title, price, body, publish date), the relationships that tie records to each other, and how each record turns into a URL. Blog posts, products, docs, FAQ entries, testimonials — each is a content type you define once and then fill with records.

This guide covers what a content type is, the two ways to create one (the one-line "from scratch" path and the fully explicit path), the complete field-type catalog, relationships between types, how a type maps to URLs, and the sharp edges when you change a type that already has content.


Anatomy of a content type

Every content type is made of these parts:

Part What it is Notes
name The machine name, e.g. blog_post. Lowercase/snake_case. This is the type's identity — you can't rename it after creation; delete and recreate instead.
label Human display label, e.g. Blog Post. Auto-derived Title Case from name if you omit it.
nodeLabel The PascalCase graph label, e.g. BlogPost. Auto-derived from name if omitted. Locked after creation (changing it would orphan records — 409).
keyField The primary identifying field, e.g. slug. Defaults to slug. Must be one of the declared fields. Locked after creation (409).
fields[] The list of field definitions (below). The shape of one record.
relationships[] Typed edges to other content types. E.g. a lesson belongs to a course.
routePattern URL template for detail pages, e.g. /{slug}.html. Optional. Omit for the default section URL. See Routing.
emitPages / listingOnly Whether the build emits a detail page per record. Defaults to page-emitting. See below.
allowedChildren, icon, derivedFrom Admin-UI conveniences (nesting rules, icon, lineage). derivedFrom is metadata only — it copies fields at create time, it isn't a live link.

A single field definition looks like this:

Key Meaning
name The field key, e.g. price.
type One of the field types in the catalog below.
required If true, the value must be present and non-empty when a record is written.
unique Marks the field's value as unique; also keeps build-time URL collision checks honest.
default A declared default value, stored on the schema (e.g. status defaults to "draft").
values The allowed values — required for enum fields.
minLength / maxLength Length bounds for string-ish fields.
regex A JS-flavored pattern the value must match.

The fast path — a type from just a name

You don't have to spell out a schema to get going. Give types_create (or POST /api/types) nothing but a name and StaticOwl fills in the rest:

MCP

types_create { name: "recipe" }

API

curl -X POST https://app.staticowl.com/api/types \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -H "Content-Type: application/json" \
  -d '{"name":"recipe"}'

Either call yields an immediately-editable recipe type. If you pass some fields but forget the key field, slug is prepended for you (a keyField that isn't a declared field breaks content creation).


The explicit path — define every field

For anything real, spell out the fields (and override the derived label / nodeLabel / keyField if you want):

MCP

types_create {
  name: "product",
  label: "Product",
  nodeLabel: "Product",
  keyField: "slug",
  routePattern: "/products/{slug}.html",
  fields: [
    { name: "slug",        type: "string",  required: true, unique: true },
    { name: "title",       type: "string",  required: true },
    { name: "description", type: "markdown" },
    { name: "price",       type: "number" },
    { name: "inStock",     type: "boolean", default: true },
    { name: "status",      type: "enum",    values: ["draft","live","retired"], default: "draft" },
    { name: "image",       type: "image" }
  ]
}

API

curl -X POST https://app.staticowl.com/api/types \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "product",
    "label": "Product",
    "nodeLabel": "Product",
    "keyField": "slug",
    "routePattern": "/products/{slug}.html",
    "fields": [
      { "name": "slug",        "type": "string",  "required": true, "unique": true },
      { "name": "title",       "type": "string",  "required": true },
      { "name": "description", "type": "markdown" },
      { "name": "price",       "type": "number" },
      { "name": "inStock",     "type": "boolean", "default": true },
      { "name": "status",      "type": "enum",    "values": ["draft","live","retired"], "default": "draft" },
      { "name": "image",       "type": "image" }
    ]
  }'

A successful create returns 201 {"ok":true}. Schema changes require an admin write role and a site scope (X-Site-Id, or the auto-bound sandbox on a trial key).


Field types

Set each field's type to one of these. The ones marked validated have their value shape enforced when a record is written; the rest are stored as-is (passthrough) and interpreted by the build or editor.

type Use it for Extra config Validated on write?
string Short single-line text — titles, names, slugs. minLength / maxLength / regex Yes — must be a string.
text Longer plain text — excerpts, summaries, meta descriptions. minLength / maxLength / regex Yes — must be a string.
markdown Body copy authored in Markdown. Auto-converted to HTML at build time everywhere it's read (top-level, and inside {% list %} / {% query %} / {% traverse %}). No (passthrough).
richtext Trusted HTML body. Render it with | raw or | safe. minLength / maxLength / regex Yes — must be a string.
number Prices, counts, ratings. default Yes — must be a finite number (a string like "3" is rejected).
boolean Flags — featured, in-stock, draft toggles. default Yes — must be true/false.
datetime Timestamps — publishAt, eventStart. Store an ISO-8601 string. default Yes — must parse as an ISO-8601 datetime.
enum A fixed set of choices — status: draft/live/retired. values: [...] (required) Yes — must be a string that is one of values.
json Structured blobs — arrays, nested objects. Yes — must be an object or array.
image An image URL. Store the CDN URL returned by media_upload_from_url. No (passthrough).
reference A pointer to another record, mostly emitted by importers. For real edges between types, use a relationship (below), not a reference field. No (passthrough).

The strictly-typed contract (the types with write-time validation) is: string, text, richtext, number, boolean, datetime, enum, json. The schema store itself accepts any type string — a couple more are recognized by specific subsystems but not validated: date (a plain date; prefer datetime if you want it validated) and stringlist (a list of strings, e.g. tags, seeded by some starter kits and importers). A type outside this list is stored fine but gets no special validation or rendering — stick to the catalog unless you have a reason not to.

Required & default. required: true makes the field mandatory — writing a record without it (or with null/empty) is rejected at write time. default is a declared default stored on the schema and surfaced to the editor UI; it describes the field, it doesn't silently rewrite records you create through the API.


Relationships between types

Fields hold values; relationships hold edges to other content types. Declare them in relationships[]:

MCP

types_create {
  name: "lesson",
  label: "Lesson",
  fields: [
    { name: "title",    type: "string", required: true },
    { name: "filename", type: "string", required: true, unique: true },
    { name: "body",     type: "markdown" }
  ],
  relationships: [
    { name: "course", edgeLabel: "BELONGS_TO_COURSE", target: "course", required: true }
  ],
  routePattern: "/{parent.slug}/{filename}.html"
}

A relationship has:

Key Meaning
name The relationship's name on the type, e.g. course.
edgeLabel The graph edge label. Normalized to UPPERCASE with non-alphanumerics turned into _ (belongs toBELONGS_TO); it must start with a letter.
target The name of the target content type, e.g. course.
kind toOne or toMany (default toMany).
required If true, a record must point at a target before it saves.

The target may be a type that doesn't exist yet (a forward reference) — the declaration still lands, and the target edge wires up once that type exists. A type with a relationship can use {parent.slug} / {parent.<field>} in its routePattern to fold the parent into its URLs (see Routing).


URLs — routePattern, page-emitting vs listing-only

routePattern pins the exact URL shape of a type's detail pages — /{slug}.html, /products/{slug}.html, /{parent.slug}/{filename}.html. Placeholders resolve against the record's fields (and its parent's). Omit it and the type falls back to the legacy section URL (/<type>/<slug>/index.html). routePattern is collision-checked across types — two types can't claim the same URL space (you get a 409). The full placeholder and encoding reference is in Routing.

Page-emitting vs listing-only. By default every type is page-emitting: the build renders one HTML detail page per record. Some types have no meaningful URL of their own — subscription tiers, FAQ entries, testimonials, footer nav-groups — they only ever appear inside a {% list %} on some parent page. Mark those listingOnly: true:

MCP

types_create {
  name: "plan",
  label: "Subscription plan",
  listingOnly: true,
  fields: [ /* … */ ]
}

listingOnly: true is the friendly alias for emitPages: false (if you pass both, explicit emitPages wins). The effect:

Use it for things embedded in a parent page. Do not use it for anything that should be indexable and shareable on its own URL (blog posts, docs, products).


Modifying an existing type

Edit a type with types_update (MCP) or PATCH / PUT on /api/types/:name (both verbs hit the same handler). There are two very different update semantics on the same call — do not mix them up:

Scalar props are a partial patch

label, description, icon, routePattern, allowedChildren, and emitPages / listingOnly are patched individually — send only what you want to change, everything else is left alone.

# Rename the display label; nothing else changes.
curl -X PATCH https://app.staticowl.com/api/types/product \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -H "Content-Type: application/json" \
  -d '{"label":"Coffee product"}'

fields and relationships are a FULL REPLACE — not a merge

This is the one that bites people. Sending fields (or relationships, or allowedBlockTypes) deletes the type's entire existing set and recreates it from exactly what you send. Anything you leave out of the array is dropped, and the corresponding data on existing records is orphaned. There is no "add one field" call — you always send the complete array.

The safe pattern is read-modify-write: fetch the current definition, edit the array, send the whole thing back.

MCP

types_get { name: "product" }          # read the full current fields[]
types_update {
  name: "product",
  fields: [ /* the ENTIRE existing list, plus your new field */ ]
}

API

# 1. Read the current type.
curl https://app.staticowl.com/api/types/product \
  -H "Authorization: Bearer $STATICOWL_API_KEY" -H "X-Site-Id: site:mysite"

# 2. Send back the COMPLETE fields array with your edit folded in.
curl -X PATCH https://app.staticowl.com/api/types/product \
  -H "Authorization: Bearer $STATICOWL_API_KEY" \
  -H "X-Site-Id: site:mysite" \
  -H "Content-Type: application/json" \
  -d '{"fields":[ /* every existing field + the new one */ ]}'

What you can't change


Reading a type back

You want MCP API
The full definition of one type (fields, relationships, keyField, routePattern, enum values, validators) types_get { name } GET /api/types/:name
A summary of every type (name, label, emitPages, routePattern, compact {name,type} field map) types_list GET /api/types
Remove a type (its records are preserved) types_delete { name } DELETE /api/types/:name

types_get is what you call before types_update — it returns the complete fields/relationships arrays you need to resend without dropping anything.


Gotchas & tips


See also

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Zero to deployed page — the 5-minute quickstart
69% match
Routing — routePattern URL templates
65% match
StaticOwl documentation — StaticOwl Docs
62% match