---
title: Rich text
description: "One document, three views, and a node schema that is ours rather than an editor's."
---

A `richtext` field stores a **document** and serves three views of it.

```json
"body": {
  "html":  "<h2 id=\"install\">Install</h2><p>Run <code>npm i</code>…</p>",
  "json":  { "version": 2, "content": [ /* nodes */ ] },
  "plain": "Install Run npm i …",
  "toc":   [ { "level": 2, "text": "Install", "id": "install" } ]
}
```

- **`html`**: semantic HTML, ready to render. External links carry
  `rel="nofollow noopener"`. Images carry `width`/`height` when known and
  become `<figure><img><figcaption>` when captioned. A callout is
  `<aside class="callout callout-info">`. A table is a real `<table>` with
  `<thead>` and `<th>`. An embed is a responsive `<iframe>` for a known
  provider and a plain link otherwise. **Never** a third-party script.
- **`json`**: the stored document, with image URLs and embed URLs resolved
  and link `rel` added. Build your own renderer on this when you want React
  components instead of `dangerouslySetInnerHTML`.
- **`plain`**: text only. For meta descriptions, previews and search. Image
  captions and table cells are included; alt text and embed URLs are not.
- **`toc`**: the headings in order, with the *same ids* the rendered HTML
  puts on them, so `#id` lands. Build in-page navigation from this rather than
  parsing the HTML.

> **INFO: The schema is ours, not an editor's**
>
> `json` is not an editor's internal state. It is a document schema ZevContent
> defines and versions, which is what makes it safe to build a frontend on: the
> day we replace the dashboard's editor, nothing about this changes.

## The node schema

Current version: **2**. Both 1 and 2 are accepted on write.

**Block nodes:** `paragraph`, `heading`, `bulletList`, `orderedList`,
`listItem`, `blockquote`, `codeBlock`, `callout`, `table`, `tableRow`,
`tableCell`, `tableHeader`, `image`, `embed`, `horizontalRule`.

**Marks:** `bold`, `italic`, `underline`, `strike`, `code`, `highlight`,
`link`.

**Heading levels:** 2, 3 and 4. Level 1 belongs to the page, not to the body
of a field.

`listItem` appears only inside a list, and `tableRow`/`tableCell`/`tableHeader`
only inside a table. Every other node may appear at the top level.

## Versions

Send `"version": 2`. A document sent as version 1 is accepted, because schema 2
is a superset of schema 1, and stored as 2. A version above 2 is refused.

A document **stored** at version 1 keeps saying 1 when you read it, and renders
exactly as it always did. Callouts, tables, embeds and image captions are the
schema 2 additions.

## Images inside a document

An `image` node takes **either** `assetId` **or** `src`: exactly one, never
both and never neither.

- `assetId` is a `med_…` id from this project's media library. It resolves to a
  URL on every read, and on a project with a private bucket gains
  `url_expires_at`.
- `src` is an absolute `http(s)` URL to an image hosted elsewhere, stored
  verbatim. Nothing else is accepted: no `data:`, no protocol-relative
  `//host`, no `/path`, no `#fragment`.

Reading one back, it carries `"source": "library"` or `"external"` and a `url`.
An external image's URL *is* its stored `src`: it never expires, we never fetch
it, and the server it lives on is not ours, so it can move, disappear or block
hotlinking. That is the trade-off of pasting a URL in rather than uploading.

`alt` is for a screen reader; `caption` is printed under the picture and counts
toward the reading time.

## Callouts, tables and embeds

**Callouts** take `{ "variant": "info" | "warning" | "success" | "danger" }`
and hold paragraphs and lists. The variant is the callout's *meaning* and
becomes `class="callout callout-…"` on the rendered `<aside>`.

**Tables** hold `tableRow`; a row holds `tableCell` or `tableHeader`; a cell
takes `colspan` and `rowspan` (positive integers, default 1). Leading rows made
entirely of `tableHeader` become the `<thead>`.

**Embeds** take a `{ "url" }`, held to the same rule as a link `href`.
`provider` and `embedId` are **derived by the server** and any values you send
for them are discarded. `youtube`, `vimeo`, `x` and `codepen` get a responsive
iframe; anything else is `generic` and renders as a link.

> **WARNING: No embed ever emits a script tag**
>
> This is a hard rule, not a current limitation. Letting a content field inject
> third-party JavaScript into your page would make every writer a deploy of
> arbitrary code onto your site.

## Writing a document

Unknown **marks** are dropped on save. Unknown **node types** are refused with
an error naming what is allowed (`unknown_richtext_node`), because silently
dropping a node loses content somebody wrote.

`link` marks take `{ "href" }`. `rel` and `target` are set by the renderer and
never stored.

If you are generating documents programmatically, `/v1/introspection` returns
this whole contract under `rich_text`: every node type, mark, heading level,
callout variant and embed provider, as arrays you can validate against.