Content modelling

Portable Text

Rich text as structured data: blocks, styles, lists, decorators, annotations and embedded objects — and why that beats storing HTML.

Portable Text stores rich text as an array of blocks. Marks are separated from the text they apply to, annotations live in markDefs, and anything that is not prose is an ordinary object in the same array.

json
[
  {
    "_type": "block",
    "_key": "a1",
    "style": "normal",
    "markDefs": [{ "_key": "l1", "_type": "link", "href": "/docs" }],
    "children": [
      { "_type": "span", "text": "See ", "marks": [] },
      { "_type": "span", "text": "the docs", "marks": ["l1", "strong"] }
    ]
  },
  { "_type": "codeBlock", "_key": "a2", "language": "bash", "code": "npm install" }
]

Why not HTML

Because the same document has to render to a web page, a native app, a plain-text search index and a summary — and every one of those would otherwise be parsing markup. It is also what makes the platform's own full-text search possible: extracting plain text from Portable Text is a walk over a tree, not an HTML parse.

What the example schema declares

  • Styles: normal, h2, h3, h4, blockquote. No h1 — the document's title is the page's only one.
  • Lists: bullet and numbered, with levels.
  • Decorators: strong, emphasis, code.
  • Annotations: a link object with an href and a "new tab" flag.
  • Embedded objects: images with required alt text, callouts, code blocks and tables.

Each embedded object earns its place by being something a text block cannot express. A code block's whitespace is content, and spans normalise it; a table has no Portable Text primitive at all.

The two rules a renderer must follow

  1. Never drop an unknown type. A member the renderer has no component for should be rendered visibly as such. Silence looks exactly like an editor who did not write anything.
  2. Never trust an href. Studio validation does not gate the API, so classify every URL against an allow-list — root-relative, http, https, mailto — and render anything else as plain text.