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:
nodeLabelis derived PascalCase (recipe→Recipe,blog_post→BlogPost).labelis derived Title Case (blog_post→Blog Post).keyFielddefaults toslug.- Because you gave no fields, it seeds a usable starter set:
title(string, required),slug(string, required, unique), andbody(markdown).
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 to → BELONGS_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:
- No detail pages are emitted — no orphan
/plan/<slug>/pages. - Records still live in the graph —
{% list type:"plan" %}returns them,content_getworks, publishing still stamps them. - The type still shows up in the admin — you edit plans normally.
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
nodeLabelandkeyFieldare locked. Sending either returns409— they are semantic identity. Delete and recreate the type to change them.- Dropping the keyField out of a
fieldsarray while records exist is refused (409).
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
types_updatewithfieldsis a replace, never a merge. Always read the current definition withtypes_getfirst, edit the array, and send the whole thing back. A partialfields[]silently drops every field you omitted — and orphans their data.nodeLabelandkeyFieldcan't change. They're baked at create time. Trying to change them 409s. Delete + recreate if you truly need a different label or key.enumneedsvalues. Without avalues: [...]array anenumfield has no membership to enforce — it behaves like a plain string.markdownrenders;textandrichtextdon't. Amarkdownfield is auto-converted to HTML at build time everywhere it's read.textstays HTML-escaped (add| safeto unescape) andrichtextis trusted HTML you render with| raw. Pick the type to match how you'll template the value.- Clearing a
routePattern. SendingroutePattern: null(or an empty string) reverts the type to the legacy/<type>/<slug>/fallback. If you round-trip a type throughtypes_get→ edit →types_update, don't sendroutePatternas""/nullunless you actually mean to clear it — a value replaces,null/""clears, and omitting the key leaves it as-is. routePatterncollisions 409. Two types can't emit pages to the same URL space. Pick a more discriminating pattern (or mark a fieldunique: true).edgeLabelgets uppercased.{ edgeLabel: "belongs to" }is stored asBELONGS_TO. It must start with a letter and reduce toA-Z,0-9,_.numberandbooleanare strict.price: "18.50"(a string) is rejected — send18.50. Same for booleans: sendtrue, not"true".- Deleting a type keeps its content.
types_deleteremoves the schema but leaves existing records in the graph; their type pointer just dangles. Clean up the content separately if you meant to remove it.
See also
- Zero to deployed — the 5-minute path from a new key to a live, data-driven page (defines a type in step 2).
- Routing —
routePattern— the full URL-template reference: placeholders, encoding, locale interaction, and collision handling. - Template reference — how to render your fields:
{% list %}, escape/raw/mdfilters,{% similar %}, and more. - Publishing & environments — how types, builds, preview hosts, and custom domains fit together.
- Redirects — pair a
routePatternchange with old-URL redirects so inbound links don't break.