> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nohead.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Content modelling

> Collections, fields and field types, and how to change them safely.

A **collection** is a data model: blog posts, products, team members. It has a name, a **slug** that identifies it in URLs and API calls (`blog-posts`), an optional description and a list of **fields**.

A **field** has:

* a **name** shown to editors ("Cover image");
* a **key**, the name its value has in a record's `data` (`cover_image`);
* a **type**, such as text, number or relation;
* **required**: whether every record must have a value (not for `boolean` fields: a boolean is true or false, and no value reads as false);
* **multiple**: whether the value is a list (not available for `boolean`, `long_text`, `rich_text` and `json`);
* a type-specific **configuration**, such as a maximum length or enum options (the API reference lists each type's).

## Naming rules

| | Rule |
| - | - |
| Collection slug | Starts with a letter; lowercase letters, digits, `-` and `_`; at most 63 characters. Unique in the project. |
| Field key | Starts with a letter; lowercase letters, digits and `_` (snake\_case); at most 63 characters. Unique in the collection, and a renamed field's old key stays taken while it still works (six months). |

These field keys are reserved, for records' own attributes and a few kept for later: `id`, `object`, `collection`, `data`, `status`, `published_at`, `created_at`, `updated_at`, `deleted`, `deleted_at`, `purge_after`, `revision`, `schema_version`.

## Field types

| Type | Value in `data` | Configuration |
| - | - | - |
| `text` | A string, up to 10,000 characters | `min_length`, `max_length`, `format`, `unique` (see [Formats and unique values](#formats-and-unique-values)) |
| `long_text` | A string, up to 100,000 characters | `min_length`, `max_length` |
| `rich_text` | A ProseMirror document, `{"type": "doc", "content": [...]}`, which can show [images](#images-in-rich-text) of the project's assets, up to 1,000,000 bytes. Write it as Markdown or as the document; read it as the document, HTML or Markdown ([Rich text](#rich-text)) | |
| `integer` | A whole number within ±9,007,199,254,740,991, which every JSON client represents exactly | `min`, `max`, `unique` |
| `decimal` | A number | `min`, `max` |
| `date` | A calendar date, `2026-10-09`, returned exactly as written. With `include_time`, a moment: ISO 8601 with an offset, stored in UTC | `include_time`, `time_zone` (see [Dates](#dates)) |
| `boolean` | `true` or `false`; no value reads as `false`, so it can't be required | |
| `enum` | One of the configured options, as a string | `options` (required): up to 500 unique strings |
| `asset` | An asset ID (`ast_...`) from the project's assets | `accepted_types`: the kinds of file it takes |
| `relation` | A record ID (`rec_...`) in the target collection | `target_collection_id` (required) |
| `json` | Any JSON value, up to 100,000 bytes and 32 levels of nesting. For data with a shape of its own, such as settings or another service's payload: model content as typed fields, and keep large data in an [asset](https://docs.nohead.io/guides/assets.md) | |

Every type's configuration also takes a `description` (**Help text** in the web app, up to 500 characters) shown to editors, and the search options `searchable`, `filterable` and `sortable` ([Search options](#search-options)).

A collection has at most 100 fields, and a record holds at most 2,000,000 bytes of values, all its fields together (`too_large` on `data`). A collection that needs more is usually two: move a group of its fields into a collection of their own and point to it with a [relation](#relations-and-assets).

Values are never converted between types: `"5"` is not an integer and `1` is not a boolean. With **multiple** on, the value is a list of distinct values of the field's type, such as a list of tags or photos; a repeated value is refused (`duplicate_value`).

> [!NOTE]
>
> Links inside rich text must be `http`, `https`, `mailto`, `tel` or relative URLs. Your site renders this content, so the API rejects `javascript:` and `data:` URLs.

## Formats and unique values

A `text` field can have a `format`, and values that don't match it are refused with `invalid_format`. A format sets its own length limits, so `min_length` and `max_length` are for plain text:

| Format | Takes |
| - | - |
| `slug` | Lowercase letters, digits and single hyphens, like `hello-world` (no slashes, at most 200 characters). One value per record, never a list, and always unique |
| `url` | An absolute `http` or `https` address |
| `email` | An email address, kept as entered |

`text` and `integer` fields with one value can be `unique`: no two records of the collection have the same value. Emails compare ignoring capitals, records without a value don't count, and a deleted record frees its values. A value another record has is refused with `taken`, and the error names that record's `record_id`. Turning `unique` on, in place or with a field migration (such as turning a text field into a slug), is refused while records would share a value (`duplicate_values`, naming them); fix them first.

A slug for your URLs is a text field with `format: slug`. In the web app's **Type** list, **Slug**, **URL** and **Email** are types of their own: text fields with that format. A slug is always unique (the field has `unique: true`), and editors type it themselves. Look a record up by slug with `filter[slug]=hello-world`. For slugs that only need to be unique within a section, like `intro` under several versions, use plain text.

## Dates

A plain `date` field holds a day, `YYYY-MM-DD`, with no time and no time zone. It is the same day for everyone, so a publish date or a birthday never shows as the day before. Print it as it is: in JavaScript, `new Date("2026-10-09")` is midnight UTC, which is still 8 October in the Americas.

With `include_time: true` the field holds a moment, such as an event's start. Send it with any offset (`2026-10-09T19:00:00+02:00` or `2026-10-09T17:00:00Z`); it is stored in UTC. Responses write it:

* in UTC (`2026-10-09T17:00:00Z`), by default;
* with the offset of the field's `time_zone` (an IANA name such as `Europe/Copenhagen`), daylight saving included, when the field has one: `2026-10-09T19:00:00+02:00`, and `2026-12-01T19:00:00+01:00` in winter. Your site prints the local time without knowing the zone, and the editor shows and takes times in that zone, whoever is editing.

Changing `time_zone` never rewrites values: the moments stay, and only how they are written changes. Turning `include_time` on or off does rewrite them, so it runs as a [field migration](https://docs.nohead.io/guides/field-migrations.md). Removing the time takes a `time_zone` whose day each moment falls on (by default the field's own, else UTC), and adding it makes each date midnight in the field's zone.

## Rich text

Rich text is stored as a [ProseMirror](https://prosemirror.net) document in the web editor's vocabulary. Write it as Markdown, or as the document, and read it back as the document, as HTML or as Markdown.

### Write Markdown

A Markdown string (GitHub Flavored) in a rich text field is stored as the document it reads as:

```bash theme={null}
curl -X PATCH "https://api.nohead.io/v1/records/rec_01J9ZQ3F8X5W2K7M4N6P0R1S2T" \
  -H "Authorization: Bearer $NOHEAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"body": "## Getting there\n\nTake the **8:15** ferry, then walk.\n\n| Day | Ferry |\n| --- | --- |\n| Mon | 8:15 |"}}'
```

What the vocabulary below can't hold is converted as closely as it can be: headings below `###` become `###`, task list items plain items, and footnotes and raw HTML their text. Images are the project's assets, written `![alt](ast_...)` or with an asset's `image_url`; any other image is refused (`invalid_format`). There is no HTML input: convert HTML to Markdown first.

### The document

A document uses the web editor's nodes and marks, and nothing else, so whatever is stored edits in the web app and renders:

* **Blocks:** `paragraph`, `heading` (`level` 1 to 3), `blockquote`, `bulletList` and `orderedList` (`start`, `type`) of `listItem` (each starting with a paragraph), `codeBlock` (`language`), `horizontalRule`, `table` of `tableRow` of `tableHeader` and `tableCell` (`colspan`, `rowspan`, `colwidth`, `align`), and [images](#images-in-rich-text). Any block can have a `dir` (`auto`, `ltr` or `rtl`).
* **Inline:** `text` and `hardBreak`.
* **Marks on text:** `bold`, `italic`, `strike`, `underline`, `code` (on its own) and `link` (`href`, and optionally `title`, `target`, `rel` and `class`, which Tiptap adds).

Another editor's names are refused with `invalid_format`, naming ours where one fits: `contains an unknown mark "strong"; use "bold"`. These are Tiptap's names, so documents from a Tiptap editor with StarterKit and tables need no conversion.

### Images in rich text

An image in a rich text document is one of the project's assets, by ID, with its alt text:

```json theme={null}
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [{ "type": "text", "text": "The view from the pier." }]
    },
    {
      "type": "image",
      "attrs": {
        "asset_id": "ast_01J9ZQ3F8X5W2K7M4N6P0R1S2T",
        "alt": "A red sky over the sea"
      }
    }
  ]
}
```

* Images go between the document's blocks, not inside a list, a quote or a table. In Markdown, an image inside one moves to after it.
* A newly added image must be an uploaded JPEG, PNG, GIF, WebP, AVIF or SVG of the project; anything else is refused (`invalid_reference`, `type_not_accepted`). An image already in a document stays when its asset is deleted later.
* A document holds at most 100 images (`too_many_images`). For more, an asset field with **multiple** on suits a gallery better.
* `alt` is up to 1,000 characters, and search matches it.

In the web app, the editor's **Image** button chooses an asset or uploads one, and images can be pasted or dropped in.

### Read HTML or Markdown

Pass `rich_text=html` or `rich_text=markdown` to any request that returns records or drafts, and each rich text value is a string instead of the document:

```bash curl theme={null}
curl "https://api.nohead.io/v1/collections/blog-posts/records?rich_text=html" \
  -H "Authorization: Bearer $NOHEAD_API_KEY"
```

```ts TypeScript theme={null}
const site = nohead.withOptions({ richText: "html" })
const post = await site.records.get<{ body: string }>("rec_01J9ZQ3F8X5W2K7M4N6P0R1S2T")
console.log(post.data.body) // "<h2>Getting there</h2>\n<p>Take the <strong>8:15</strong> ferry…"
```

```python Python theme={null}
site = nohead.with_options(rich_text="html")
post = site.records.get("rec_01J9ZQ3F8X5W2K7M4N6P0R1S2T")
print(post.data["body"])
```

```ruby Ruby theme={null}
site = nohead.with_options(rich_text: "html")
post = site.records.get("rec_01J9ZQ3F8X5W2K7M4N6P0R1S2T")
puts post.data["body"]
```

* **HTML** is a fragment of the elements above (`p`, `h1`–`h3`, `ul`, `ol`, `table`, `pre`, `a`, `strong` and so on), with every text and attribute escaped, to put in a page as it is. Images are WebP renditions at most 1,600 pixels wide with their `width` and `height` (an SVG as itself); one whose asset was deleted is left out.
* **Markdown** reads back as the same document, so an agent or script can read it, change it and write it back. Markdown has no underline or merged table cells, so those are lost on the way, and text that looks like a URL or an email address comes back as a link. Images are written with their `image_url`.
* Images come with their URLs, so they need no `expand`, and no `assets:read`. Records embedded with `expand` read the same way.
* Record history and webhook payloads keep documents.

> [!WARNING]
>
> A string written to a rich text field is read as Markdown. Write back Markdown or the document, never the HTML: its tags would be kept as text.

To render documents yourself, expand the field instead, which needs the key's `assets:read` scope: `expanded` then lists the assets its images show, in order, with their dimensions and `image_url`. Write the size your page needs into each image's URL ([Images](https://docs.nohead.io/guides/images.md)), with no further request:

```bash theme={null}
curl "https://api.nohead.io/v1/collections/blog-posts/records?expand=body" \
  -H "Authorization: Bearer $NOHEAD_API_KEY"
```

A published-only key can read the images that published records show, and nothing of unpublished ones. Alt text is plain text: escape it when you render it, as you would any value.

## Relations and assets

A relation field points at records in another collection of the same project, set by `target_collection_id`. An asset field points at an uploaded file.

An asset field takes any file unless you limit it with `accepted_types`: MIME types or `type/*` wildcards, such as `["image/*", "application/pdf"]`. In the web app, these are the field's **Allowed files** (images, videos, audio, PDFs). The limit applies to newly written values: files already in records are kept, and a new file of another type is refused (`type_not_accepted`). To list only the assets a field takes, pass its types to `GET /v1/projects/{project_id}/assets`: one as `filter[content_type]`, several as `filter[content_type][in]` (comma-separated). In the SDKs, pass `content_type` as a list.

Records store only the IDs. [Reading content](https://docs.nohead.io/guides/reading-content.md#embed-related-records-and-assets) embeds the related records and assets in the same response with `expand`.

## Search options

To filter and sort records, see [Reading content](https://docs.nohead.io/guides/reading-content.md#filter).

Every collection is indexed for full-text search. Members search in the web app, API keys with `search:read` through the [API](https://docs.nohead.io/guides/api-search.md), and sites and apps with a [search token](https://docs.nohead.io/guides/public-search.md), which searches only collections with **Include in public search** on. The `searchable`, `filterable` and `sortable` options choose how each field takes part, in every search: words match `searchable` fields, and filters and sorts need `filterable` and `sortable` ones. API searches take the same filter operators, and `filter[published_at][exists]` (`true` for published records, `false` for unpublished ones). `searchable` decides relevance, not privacy: whoever can search a collection can also read its records.

## Changing a collection

You can change a collection's name and description at any time. Adding fields, renaming them and loosening their options is safe: record data is stored by field ID, so it never moves.

* **Renaming a field's key** keeps its values. Records come back under the new key, and requests that use the old key keep working for six months, so your code has time to switch.
* **Changing a field's type, making it multiple, or adding or removing a date's time** rewrites stored values, so it runs as a [field migration](https://docs.nohead.io/guides/field-migrations.md). You can preview how many records convert cleanly before you start, and from the start the collection's fields can't change, and once it is running its records can't either.
* **Tightening a field** (making it required, lowering a maximum, removing enum options) is refused while existing records would break the new rule. Fix those records first, or run a [field migration](https://docs.nohead.io/guides/field-migrations.md) that clears the values that break it or fills in records without a value.
* **Deleting a field or collection** is a soft delete, and can be undone for 30 days (until its `purge_after`).

Each schema change bumps the collection's schema version, and each record carries the `schema_version` it was last written with.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.