# The content model (/docs/guides/content-model)

Every space defines its own content model: the **content types** its entries are made of. Each [environment](/docs/guides/environments) 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](/docs/guides/model-in-git)).

## Content types [#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](/docs/guides/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**).

| Kind    | Its entries                                                                                                                                                                                      |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `page`  | Have a slug and a place in the space's tree, so a URL path (`blog/hello`). Fetch one by path from the delivery API.                                                                              |
| `data`  | Have no URL: authors, settings, navigation. They can sit in folders.                                                                                                                             |
| `block` | Are 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 [#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:

```json
{ "_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 [#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 [#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](/docs/guides/assets), 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](/docs/guides/assets#private-files). 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 [#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 [#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.

| Kind  | Starts with                                                                       |
| ----- | --------------------------------------------------------------------------------- |
| Page  | `title` (required, translated), `body` (blocks) and the SEO fields below          |
| Data  | `title` (required, translated)                                                    |
| Block | `heading` (translated), `anchor` (for links to the block on the page, `#pricing`) |

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

| Field            | Type       | For                                                                                                           |
| ---------------- | ---------- | ------------------------------------------------------------------------------------------------------------- |
| `seoTitle`       | `text`     | `<title>` and `og:title`. Falls back to the title.                                                            |
| `seoDescription` | `textarea` | `<meta name="description">` and `og:description`.                                                             |
| `shareImage`     | `asset`    | `og:image`, 1200 × 630.                                                                                       |
| `canonicalUrl`   | `text`     | `<link rel="canonical">` when the content was published somewhere else first. Empty means the page's own URL. |
| `noIndex`        | `boolean`  | `<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 [#fields]

Every field has an `apiId`, a `name`, and optionally a `description`, `required`, `localized` and [`when`](#fields-shown-only-sometimes).

| Type        | Options                                                                                                                            | An entry stores                                                                                        |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `text`      | `minLength`, `maxLength`, `pattern` (a regular expression), `format` (`url`, `email`, `color` or `slug`), `unique`, `defaultValue` | A single-line string                                                                                   |
| `textarea`  | `minLength`, `maxLength`, `pattern`, `defaultValue`                                                                                | A string                                                                                               |
| `richtext`  |                                                                                                                                    | A [rich text document](/docs/api/rich-text)                                                            |
| `number`    | `integer`, `min`, `max`, `unique`, `defaultValue`                                                                                  | A number                                                                                               |
| `boolean`   | `defaultValue`                                                                                                                     | `true` or `false`                                                                                      |
| `datetime`  | `withTime`                                                                                                                         | `YYYY-MM-DD`, or an ISO 8601 date-time with `withTime`                                                 |
| `select`    | `options` (`[{ value, label }]`), `multiple`, `defaultValue`                                                                       | An option's value, or a list of them                                                                   |
| `asset`     | `mimeTypes` (prefixes, e.g. `["image/"]` or `["image/*"]`), `multiple`, `min`, `max`                                               | An [asset](/docs/guides/assets) id, or a list                                                          |
| `reference` | `contentTypes` (the page or data types allowed; any when left out), `multiple`, `min`, `max`                                       | An entry id, or a list                                                                                 |
| `link`      | `contentTypes` (the page types allowed; any when left out), `products` (it may also go to a Shopify product)                       | A [link](#links): `{ entry }`, `{ url }` or `{ product }`, with `query`, `hash` and `target: "_blank"` |
| `blocks`    | `allowedBlocks` (block types; any when left out), `min`, `max`                                                                     | A list of blocks: `{ _uid, _type, ...fields }`                                                         |
| `shopify`   | `resource` (`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                                    |
| `json`      |                                                                                                                                    | Any 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](/docs/guides/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](/docs/guides/shopify#product-pages) type, and a unique **Collection** field a [collection page](/docs/guides/shopify#collection-pages) 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](/docs/guides/locales).
- 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](/docs/guides/ab-tests), different blocks per variant of an A/B test or feature flag, with no content type to create.

### Fields shown only sometimes [#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:

```json
[
	{ "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 }
	}
]
```

| Condition         | Shows the field while the earlier field                                            |
| ----------------- | ---------------------------------------------------------------------------------- |
| `equals: true`    | An 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: true`    | Any 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](/docs/api/delivery#filtering-and-ordering) (`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.

## Links [#links]

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](/docs/guides/shopify#links-to-products), `{ "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](/docs/guides/shopify#product-pages) 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.

```json
{
	"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](/docs/api/delivery#links) 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`](/docs/sdk/javascript#links).

## Changing the model [#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](/docs/guides/model-in-git#locking-the-model-to-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](/docs/guides/webhooks).

## The model file [#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.

```json
{
	"$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](/docs/sdk/cli).
