Guides · Modelling

The content model

Page, data and block types, every field type and the value an entry stores for it, and which changes to the model are refused while content would break.

16 min read

Every space defines its own content model: the content types its entries are made of. Each environment has its own copy, so a change can be tried in develop before it reaches production. Edit it under Content model in the dashboard, which lists every type with what uses it — open one to change its fields and settings — or keep it in a repository (the model in Git).

Content types

A content type has an API id (blogPost: a lowercase letter, then letters, digits and underscores), a name, a description, a kind and a list of fields.

The description says what the type is for, in a sentence: "The large opening section of a page: a heading, text and calls to action." Editors see it when they choose a type, and prompt editing reads it to pick the block type for "add a section about pricing", so say what the type is for rather than what it holds. Every type needs one. Types from before it was required have none: the model can't change until each of them is given one (the dashboard marks them under Content model).

KindIts entries
pageHave a slug and a place in the space's tree, so a URL path (blog/hello). Fetch one by path from the delivery API.
dataHave no URL: authors, settings, navigation. They can sit in folders.
blockAre the building blocks of a page (a hero, a quote, a call to action), inside a blocks field. A block type can also have shared blocks: entries made on their own and reused across pages.

Folders are entries without a content type that group pages and data entries in the tree.

Shared blocks

A block type can have entries of its own, shared blocks: a block made once under Content and shown on every page that uses it — a banner, a footer call to action. They have no URL and no place in the tree; New entry offers block types after pages and data types.

A blocks field holds a reference to one:

{ "_uid": "b7", "_type": "_shared", "entry": "entry_…" }

It takes a type the field's allowedBlocks allows. Delivery answers the block itself, { "_uid", "_type": "hero", "_sharedFrom": { "id", "name" }, …fields }, so a site renders it as any block. A shared block can't contain another shared block, and it can't be put in a data entry's blocks field.

Editing a shared block changes every page that shows it. Edit shared block on its card opens it, and the publish options list the live pages, all ticked: untick one to keep it on the version it shows now; it gets the new one the next time it is published. A block can't be deleted or unpublished while a page's draft or published copy uses it.

Groups, accent and code

Three settings only change how the dashboard shows a type; delivery and the SDK ignore them:

  • Groups (groups) are the sections of the entry editor: [{ "id": "hero", "label": "Hero", "fieldIds": ["heroTitle", "heroImage"] }], in order. A group without a label is named by the editor in its own language: fields "Fields", seo "Search and sharing" (with a preview of the search result), blocks-<apiId> after its blocks field. Every field is in one group, and within a group the fields keep the order of fields. A field in no group joins the group of the field before it. On Content model, drag a field by its grip (or press Space on it, move it with the arrow keys and press Space again) to move it within or between groups.
  • Accent (accent) is the tint of the type's tile in the content list, ⌘K and the editor: coral, butter, lilac, sky or mint. A new type gets its kind's: butter for pages, sky for data, lilac for blocks.
  • Code (code) is the one or two characters on the tile. Empty shows the first letters of the name ("Home page" HP).

The agent API, MCP and model files keep these as they are when a type is saved without them, so an older model file or script never undoes them. A new type starts with its plain fields in Fields, each blocks field in a group of its own and the SEO fields in Search and sharing.

Preview images

A block type can have a preview image: a picture of what the block looks like on the site, shown above its name and description when editors add a block, in the entry form, the canvas and on the site. With twenty blocks to choose from, "Hero (split)" and "Hero (centered)" are told apart at a glance.

Set it under Preview image in the block type's settings: Upload a screenshot (it goes to the space's media, and the type stores its id), or paste an https:// URL of a picture your site or design system already hosts. In the model file and the APIs it is previewImage: an https:// URL or a media asset id (asset_…), which must be an image in this space.

  • A 16:9 screenshot works best: the picker shows the top of the picture at that ratio.
  • An uploaded file is served like any other media file, with a signed URL when it is private. A URL is loaded by editors' browsers as it is.
  • A model file with previewImage URLs moves between spaces as it is; asset ids only mean something in the space whose media holds the file.

Starting a space from a model

A new space can start with a content model instead of an empty one (Start with when creating it, or starter in the agent API), in every environment:

  • Blog: blog posts (date, author, categories, cover image), authors, categories and pages.
  • Marketing site: pages built from hero, features, testimonial, text and image blocks, buttons, and navigation entries.
  • Docs: docs pages with text, image, callout and code blocks.

It is only a start: change it like any model.

New types start with the usual fields

A new content type in the dashboard isn't empty: it starts with the usual fields for its kind. Change or remove any of them before you save. Switching the kind swaps them while you haven't changed them yet.

The agent API and MCP do the same: creating a type adds the template fields it doesn't name, so asking an agent for "a blog post type" gives you these fields as well as the blog post's own. Send "template": false to save exactly the fields you send. Replacing an existing type and applying a model file never add fields.

KindStarts with
Pagetitle (required, translated), body (blocks) and the SEO fields below
Datatitle (required, translated)
Blockheading (translated), anchor (for links to the block on the page, #pricing)

A page type's SEO fields. None of them is required:

FieldTypeFor
seoTitletext<title> and og:title. Falls back to the title.
seoDescriptiontextarea<meta name="description"> and og:description.
shareImageassetog:image, 1200 × 630.
canonicalUrltext<link rel="canonical"> when the content was published somewhere else first. Empty means the page's own URL.
noIndexboolean<meta name="robots" content="noindex">. Also leave the page out of the sitemap.

Image alt text belongs to the asset (and is translated), so image fields need no alt field of their own.

Fields

Every field has an apiId, a name, and optionally a description, required, localized and when.

TypeOptionsAn entry stores
textminLength, maxLength, pattern (a regular expression), format (url, email, color or slug), unique, defaultValueA single-line string
textareaminLength, maxLength, pattern, defaultValueA string
richtextA rich text document
numberinteger, min, max, unique, defaultValueA number
booleandefaultValuetrue or false
datetimewithTimeYYYY-MM-DD, or an ISO 8601 date-time with withTime
selectoptions ([{ value, label }]), multiple, defaultValueAn option's value, or a list of them
assetmimeTypes (prefixes, e.g. ["image/"] or ["image/*"]), multiple, min, maxAn asset id, or a list
referencecontentTypes (the page or data types allowed; any when left out), multiple, min, maxAn entry id, or a list
linkcontentTypes (the page types allowed; any when left out), products (it may also go to a Shopify product)A link: { entry }, { url } or { product }, with query, hash and target: "_blank"
blocksallowedBlocks (block types; any when left out), min, maxA list of blocks: { _uid, _type, ...fields }
shopifyresource (collection or product), multiple, min, max, unique (one item, on a page type){ id, handle } of an item in the space's Shopify store, or a list
jsonAny JSON
  • Required fields may be empty in a draft; publishing checks them, as it checks min for blocks and for multiple assets and references. max is checked on every save.
  • shopify fields (Shopify) pick from the Shopify store connected to the space on www (the same store in every environment). An item is stored as its GID (gid://shopify/Product/123) and the handle it had when picked, and delivered as is: the site reads prices, stock and images from Shopify's Storefront API by the id (Settings' "Copy as .env" has the store and a Storefront token). A handle changed in Shopify later stays as picked, so link by handle but fetch by id.
  • format checks a text field's value: url (https://…, or a path starting with /), email, color (a hex color, #rgb to #rrggbbaa) or slug (lowercase letters, digits and dashes). The value stays a string. An empty value passes, so combine it with required. A color field stores the value normalized: lowercase, and #rgb/#rgba expanded to #rrggbb/#rrggbbaa, so whatever an editor or an API call sends, the site reads one spelling.
  • unique text and number fields (on page and data types) may not have the same value as another published entry of the type, per locale when localized. Publishing checks it; drafts may hold anything meanwhile. A unique Product field (shopify, one product, not localized, on a page type) makes the type a product page type, and a unique Collection field a collection page type; a type has at most one.
  • defaultValue is what a new entry starts with (in the default locale, for a localized field) and a block added in the editor (in the locale being edited). It has to be a valid value of the field.
  • Publishing also needs every entry the content references or embeds to be published, so published content never points at a draft. Unpublishing is refused the other way round, while published content uses the entry.
  • A localized field stores one value per locale, { "en": …, "nl": … }, also inside blocks. The delivery API resolves it to one locale, falling back along the space's locale chain.
  • Every block has a _uid, unique within its entry, assigned on save. Keep it when sending blocks back: the canvas finds blocks on the page by it.
  • Rich text may be written as Markdown; it is stored as a document.
  • Every blocks field also takes the built-in improve block, different blocks per variant of an A/B test or feature flag, with no content type to create.

Fields shown only sometimes

A block often comes in a few shapes: a hero with or without a call to action, a media block that is an image or a video. Instead of a block type per shape, give the fields of one shape a Show only when (when), and they are only there while an earlier field holds a value:

[
	{ "apiId": "hasCta", "name": "Has CTA", "type": "boolean" },
	{
		"apiId": "ctaLabel",
		"name": "CTA label",
		"type": "text",
		"required": true,
		"localized": true,
		"when": { "field": "hasCta", "equals": true }
	},
	{
		"apiId": "videoUrl",
		"name": "Video URL",
		"type": "text",
		"when": { "field": "media", "in": ["video", "embed"] }
	},
	{
		"apiId": "caption",
		"name": "Caption",
		"type": "text",
		"when": { "field": "image", "filled": true }
	}
]
ConditionShows the field while the earlier field
equals: trueAn on/off field is on (false: off)
equals: "split"A select has that option, or a text field has that text (spaces around it ignored)
in: [ … ]A select has one of these options (with multiple: any of them), or a text is one
filled: trueAny field has a value
  • The field it depends on comes earlier in the same type (or the same block), and isn't localized: a field is there or not in every language alike. A localized field can depend on one, like ctaLabel above.
  • A field whose own controller is hidden is hidden too, so a chain of them is "and".
  • Hidden values are kept. Turning hasCta off and on again gives the label back. A site never gets them: the delivery API answers null for a hidden field, for delivery and preview tokens alike.
  • Required only while shown. Publishing skips required and min on a hidden field. Its stored value is still checked for its shape, and the entry form shows a problem in one on the field it depends on.
  • A hidden field can't be filtered or ordered on (400): its stored value isn't what sites get. A unique field can't be shown only sometimes.
  • Removing, renaming or translating the field a condition names, or removing an option it names, is refused until the condition changes too, in the same save or model file.
  • Prompts don't see hidden fields (and keep them), and translating skips them.

A link field is a link on the site, for buttons, cards and menus. It goes to one of three places:

  • A page of the space, { "entry": "entry_…" }: chosen in the editor like a reference, so it follows the page when it moves or is renamed. Like a reference, publishing needs the page to be published, and the page can't be unpublished while published content links to it. Delivery names the page without inlining its fields.
  • An address, { "url": "https://…" }: another site, but also a path of the site the space doesn't have a page for (/search), an anchor (#faq), mailto: or tel:.
  • A product of the space's Shopify store, { "product": { "id": "gid://shopify/Product/…", "handle": "…" } }: when the field allows Shopify products (products: true) and the space has a store, picked with the store's product picker. Nothing is imported; delivery adds the product's product page when it has one.

Each one takes a query (utm_source=newsletter, without ?) and a hash (pricing, without #), and may hold target: "_blank" to ask the browser to open the link in a new tab. Localize the field to link somewhere else per language.

{
	"cta": { "entry": "entry_…", "query": "plan=pro", "hash": "pricing" },
	"footer": { "url": "https://github.com/Obelism" }
}

Rich text has links of its own (a mark on the text); a link field is for a link that stands on its own. The delivery API answers a link with the page's id, title, slug and path, or null while the page isn't published, and a product with its product page or null; the site turns the path into an address with its own routing, or with the SDK's linkHref.

Changing the model

Admins and developers change the model; editors and readers don't see Content model, only the entry form it makes. Under Content model, the types are listed by kind, each with its description, its fields and how many entries use it: filter by kind or search, then open one. Its Fields tab shows the fields in their groups; click one to change it in the drawer beside them (a sheet on a small screen), with Done to close it and Save content type to save the whole type. Settings has the name, description, kind, colour and code, Editor preview what an editor sees on an entry of the type. The breadcrumb goes back to the list. When the model is managed in code, everything there is read-only; Download model is still on the list.

Duplicate content type (on a type's Settings tab) makes a copy with all its fields as <apiId>Copy, " (copy)" after its name. No entries come along.

Adding fields and types is always possible. Some changes are refused while content would break:

  • a field's type can change only when the values still fit: text, textarea, number, boolean, datetime and select to json, json to text or textarea, and text and textarea between them (the values are converted); on those types and json, whether it is localized can switch either way, keeping the default locale's value (turning localization off drops the other locales' values). Fields that point at entries or files keep their localization. Other type changes need the field removed and added;
  • a content type can't change kind while it has entries (or, for a block, while content contains it or it has shared blocks);
  • a type can't be deleted while it has entries (a block type: its shared blocks, or content that contains it), or while other types use it in a reference, link or blocks field.

Removing a field deletes its values from every entry that has them, drafts and published content alike; the plan says how many entries that is before anything happens. Every change to the model sends a model.changed webhook.

The model file

Content model → Download model saves the environment's model as one JSON file, and Upload model (in the ⋯ menu beside it) plans and applies one. Its $schema gives editors validation and autocomplete.

{
	"$schema": "https://content.obelism.studio/schema/content-model.v1.json",
	"version": 1,
	"contentTypes": [
		{
			"apiId": "page",
			"name": "Page",
			"kind": "page",
			"description": "A page of the site, built from hero sections.",
			"fields": [
				{
					"apiId": "title",
					"name": "Title",
					"type": "text",
					"required": true,
					"localized": true
				},
				{
					"apiId": "body",
					"name": "Body",
					"type": "blocks",
					"allowedBlocks": ["hero"]
				}
			]
		}
	]
}

Applying a file creates and updates the types in it, all or nothing. Types the file leaves out stay, unless it is applied with prune. Keep it in the site's repository with the CLI.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.

Stuck on a step?