Docs
Dashboard

Concepts

Rich text

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.

"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.

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.

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.

Updated at, Wednesday, September 2, 2026