Content model
Every field type a collection or map can carry, what it looks like to an editor, and the TypeScript your generated client hands your code.
Marketing builds the model in the UI; your generated client is typed from it. This page is the field vocabulary: what each type holds for an editor, and what it hands your code.
Fields
| Field | In the UI | In the client |
|---|---|---|
| Text | A single line | string |
| Rich text | The collaborative editor, on its own page | string of markdown |
| Number | A number input | number |
| Checkbox | A checkbox | boolean |
| Date | A date picker | string, YYYY-MM-DD |
| Select | One option from a list the editor maintains | a union of the option names |
| Multi-select | Several of those options | an array of that union |
| Link | A URL | string |
| Person | A member of the organization | { id, name } |
| Asset | An uploaded file | ContentAsset |
| Reference | One entry of another collection | ContentReference |
| Multi-reference | Several entries of another collection | ContentReference[] |
Every field is optional and nullable in the generated type: a field an editor has not filled in is absent, so your code decides what an empty one means.
Keys
A field’s key is its name in API form — Published date becomes
published_date — and it is what your code reads: post.fields.published_date.
Renaming a field gives it a new canonical key. The old key stays in the API
and generated client as a deprecated alias, returning the same current value,
so downstream code keeps working while engineering migrates. Key-changing
renames appear under Developers → Actions; display-only changes do not.
Deleting a field
Deleting a field takes it out of the app straight away: editors stop seeing it, and nobody can fill it in again.
Your code is told rather than broken, twice over. The key stays in your
generated types marked @deprecated, so your editor
strikes it through where you read it and your build carries on. And entries go
on returning the last value the field held, so the page reading it renders
exactly as it did before. A deletion made in the UI reaches a running dev
server within seconds, so both matter: engineering migrates on its own
schedule instead of meeting a red build, or a blank page, that someone else
caused.
A deleted key is never handed back. Creating a field with the same name again gives it a new key, so nothing inherits the values entries still hold.
Completing a field action
A deprecated key stays until someone completes its migration deliberately, from Developers → Actions in the app. Completing a rename removes only the old alias. Completing a deletion erases the field and everything written under it: values, rich-text bodies, revision history, and every remaining alias.
Completion is the one content change that can break a build, which is why nothing does it on the model’s behalf: it is a developer’s call, made once the code reading the key has moved on.
Assets
An asset field holds one uploaded file: an image, a PDF, a video. The client gets everything needed to render or link it without a second request:
const post = await stet.posts.get('hello-world');
if (post.fields.cover) {
const { url, name, contentType, size } = post.fields.cover;
// A whole URL, and public: it serves to your readers with no API key.
}
url is public delivery. It is unguessable, immutable, and cacheable forever,
so it belongs directly in an img tag or a download link. Content assets are
the only assets served this way; the files behind Stet’s own UI, like avatars,
stay behind a session.
The API returns asset URLs relative to itself, since the same bytes serve
whichever origin you reach them through, and the client joins them to the
origin in your stet.config.ts. That is why the URL you get is ready to use
on your own site, which is a different origin from Stet.
The same join reaches inside both representations of rich text: an asset an editor embedded or linked arrives as a whole URL too. HTML is rendered from the editor document and sanitised by Stet; markdown remains available for LLMs, feeds, diffs and custom renderers.
function PostBody() {
const body = post.fields.body;
if (body == null) {
return null;
}
return <article dangerouslySetInnerHTML={{ __html: body.html }} />;
}
If you call the REST API directly rather than through the client, join the
paths yourself with assetUrl and resolveAssetPaths from @stetcms/client.
Images can be resized and re-encoded on the way out by adding w, h, and
format:
<img src={`${post.fields.cover.url}?w=800&format=webp`} />
References
A reference field points at entries of another collection, which is how you model relationships Stet does not hard-code: an author on a post, related posts, a category that is itself an entry with its own fields.
The value carries enough to render a link, plus the slug to fetch the whole entry when you need it:
const post = await stet.posts.get('hello-world');
// { id, slug, title } — the collection it points at is in the field's doc comment.
post.fields.author?.title;
// Fetch the rest through the target collection.
const author = post.fields.author ? await stet.authors.get(post.fields.author.slug) : null;
A multi-reference is the same shape as an array, in the order the editor set.
Nothing cascades. Deleting a referenced entry does not delete the entry
pointing at it: a single reference reads as null afterwards, and a
multi-reference simply stops listing it. Your pages keep rendering.
Building your own structures
Stet deliberately has no taxonomy type, no repeater, and no nested groups. The primitives above compose into all three, and the shape stays yours rather than ours:
- Categories or tags with their own pages — a
Categoriescollection, and a multi-reference fromPosts. Each category is an entry, so it gets a slug, a description, an image, and analytics of its own. (A multi-select is the lighter option when a tag is only ever a label.) - A hierarchy — a reference from a collection to itself, holding the parent.
- A repeating list — a collection for the rows, and a multi-reference from whatever owns them. The rows become addressable and reusable, which a nested repeater never is.
Versions
Every entry keeps its history. Saving a field, renaming, or writing a body adds a version; a run of edits by one person folds into one so history stays readable. Open History on an entry to see who changed it and when, and restore any version, which puts back its values, title, slug, and bodies. A restore is itself a version, so it is never a one-way door.
Each entry keeps its 50 most recent versions.
Model kits
Organization owners and admins can export the current content model as a portable JSON model kit from Organization → Model kit. A kit contains the collections, maps, fields, select options, and reference relationships, with their exact slugs and field keys.
Select a kit while creating an organization to start with that model. The new organization is independently owned and contains no copied entries. Kits never include members, API keys, webhooks, analytics, assets, audit history, or other organization settings.
The audit log
Organization admins get Audit log: every change to the content and the model, who made it, and which surface it came through — the app, the AI assistant, an import, or a restore.