Schema & Modelling7 min read

Portable Text Explained: Why Structured Rich Text Wins

A marketing team pastes a press release from Google Docs into your CMS. The rich-text field swallows it whole: inline styles, a rogue font tag, a table that only renders on desktop, and a blob of HTML your React Native app cannot parse.

Published June 26, 2026

A marketing team pastes a press release from Google Docs into your CMS. The rich-text field swallows it whole: inline styles, a rogue font tag, a table that only renders on desktop, and a blob of HTML your React Native app cannot parse. Three months later you redesign the site, and every one of those documents has to be reopened and cleaned by hand because the formatting was baked into the content. This is the failure mode of HTML-as-content, and it is the quiet tax on every team that treats rich text as a string instead of as data.

Sanity is the headless content platform built around the opposite premise: rich text should be structured data you can query, transform, and render anywhere, not a markup string you hope renders the same twice. That format is Portable Text, and it is one piece of the Content Operating System that lets teams model content once and power any channel from it.

This guide explains what Portable Text actually is, why structured rich text beats stored HTML and Markdown for any multi-channel or long-lived content operation, and how to model, query, and render it without re-cleaning your archive every redesign.

The problem with rich text as a string

Most CMSes store rich text the way a word processor does: as a markup blob. The editor produces HTML, the database stores that HTML, and your frontend dumps it into a div with `dangerouslySetInnerHTML` or its equivalent. This works for exactly one rendering target, the web page the markup was authored against, and quietly fails everywhere else.

Consider what is actually trapped inside that string. Presentation is fused to meaning: a heading is an `<h2>` with a class, not a typed object that says "this is a section heading." A pull quote is a `<blockquote>` with inline styles rather than a quote with an attributed source. When you ship a native app, an email template, a voice interface, or a design-system refresh, none of that markup maps cleanly to the new surface. You either render HTML in a webview and accept the visual mismatch, or you write fragile regex to strip and re-tag it.

The deeper cost is that stored HTML is not queryable. You cannot ask "find every article that links to a deprecated product page" or "list every image missing alt text" when those facts are buried in escaped markup. The content is opaque to your own systems. And because the formatting decisions were made at author time against one design, a redesign means reopening documents by hand. The string format optimizes for the first render and penalizes every render after it. Structured rich text inverts that tradeoff: it stores intent, not appearance, and resolves appearance at render time per channel.

What Portable Text actually is

Portable Text is a JSON specification for rich text. Instead of a markup string, a document field is an array of blocks. Each block is a typed object. A normal paragraph is a block of type `block` containing an array of `children` spans, where each span carries text and a list of `marks`. Marks split into two kinds: simple decorators like `strong` or `em`, and annotations like a link, which reference a `markDef` object that holds structured data such as the href, whether it opens in a new tab, or a reference to another document.

The consequence of this shape is that formatting is data you can reason about. A link is not `<a href="...">`; it is an annotation object you can validate, query, and rewrite programmatically. You can also drop entirely custom block types into the same array: an image with a typed caption and hotspot, an embedded product reference, a code block with a language attribute, a callout component. The frontend decides how each type renders, so a single Portable Text array can produce semantic HTML on the web, native components in a mobile app, and clean plain text for an email digest or an answer engine.

Because it is plain JSON with a documented shape, Portable Text is portable in the literal sense: it is not coupled to Sanity at render time. The open-source serializers (`@portabletext/react`, `@portabletext/to-html`, and equivalents for other stacks) take the array and a map of components, and you control every output. Nothing about your content is locked to a presentation layer, which is exactly what stored HTML cannot promise.

Editing structured rich text without losing the writer

The objection to structured content is always the same: writers do not want to fill in forms, they want to write. The trap many platforms fall into is exposing the structure so aggressively that authoring feels like data entry. The goal is the opposite. The editor should feel like a familiar rich-text surface while producing structured data underneath.

In Sanity, the Portable Text Editor is that surface inside Sanity Studio. A writer sees a normal editing experience with bold, italic, headings, and lists, but every keystroke is serialized into the block-and-span model described above, not into HTML. Where it diverges from a word processor is the custom types you define in your schema. Because the Studio is a React application you ship rather than a fixed editor you rent, you decide which block types, annotations, and inline objects appear in the toolbar, and you can supply custom input components for each. A "product reference" annotation can open a searchable picker; an embedded callout can render its own mini-form inline.

This is the practical payoff of an editor you control with code. You constrain authors to the shapes your design system actually supports, so a writer cannot paste a five-level-deep table that breaks on mobile, because that shape does not exist in the schema. The writing stays fluid, but the output stays clean and predictable. Structure becomes a guardrail rather than a tax, which is the only way structured rich text survives contact with a real editorial team over years of use.

Querying inside the prose, not just around it

Stored HTML treats a rich-text field as an atom: you fetch the whole blob or nothing. Structured rich text lets you query the content of the prose itself, and this is where the operational difference compounds over a large content set.

Because Portable Text is an array of typed objects living in Content Lake, GROQ can reach inside it. You can project just the plain text of a body field for a search index, count the blocks of a given custom type, or filter documents by what their annotations reference. A query can walk a link annotation's reference with the `->` dereference operator and pull the title of the linked document in the same round trip, so "every article linking to this product" stops being a manual audit and becomes one query. GROQ asks for exactly the shape you need, projections, references, filters, and array traversal included, rather than over-fetching a blob you then parse on the client.

This turns governance tasks that are painful against markup into ordinary reads. Find every image block missing an `alt` field. List documents whose body still references a retired campaign. Build a reading-time estimate by reducing over the spans. None of these require parsing HTML or maintaining a brittle scraper, because the facts are first-class fields rather than substrings. When your content is data all the way down, your own tooling can finally treat it that way, and the archive becomes an asset you can interrogate instead of a liability you migrate.

Rendering the same content to many channels

The promise of headless is one content model feeding many frontends, but stored HTML breaks that promise the moment a second channel appears. HTML authored for a web layout is full of assumptions the next channel does not share. Portable Text keeps the promise because the array carries meaning and defers appearance to each renderer.

The mechanism is the serializer pattern. You pass the same Portable Text array to `@portabletext/react` on a Next.js or Remix site, to a native renderer in a mobile app, and to a plain-text serializer for an email or an LLM context window, and you supply each one a map from block and mark types to components. The web renderer turns a `callout` block into a styled component; the email renderer turns it into a bordered table cell; the plain-text serializer flattens it to a sentence. The content did not change. Only the component map did.

This is also why structured rich text is the right substrate for AI and answer-engine consumption. An LLM or an agent reading your content does not want a tangle of presentational divs; it wants clean, typed text with the structure intact, which Portable Text provides natively. Pair that with Visual Editing and the Presentation Tool, and editors can still click an element in a live preview and land on the exact block in the Studio, so you get channel-independent content without surrendering the visual, click-to-edit experience that made page-coupled editing feel comfortable in the first place. One model, many surfaces, no re-cleaning.

Migrating off stored markup without a rewrite

Teams sitting on years of HTML or Markdown understandably fear that adopting structured rich text means a hand re-keying project. It does not have to. Because Portable Text has a documented, deterministic shape, the migration is a transformation problem, not an editorial one, and it runs once as a script rather than dragging on as a backlog.

The path is to parse your existing markup into an abstract tree (most HTML and Markdown toolchains already produce one) and map each node type to a Portable Text block or mark. A heading node becomes a block with the appropriate style; an anchor becomes a span with a link annotation and a `markDef`; an image becomes a custom image block with its `alt` and asset reference. The HTML-to-Portable-Text and block-content tooling in the Sanity ecosystem exists specifically for this conversion, and because the target is JSON with a schema, you can validate the result and catch the shapes that did not map cleanly instead of discovering them in production.

The honest caveat: edge cases in old markup, inline styles, nested tables, vendor-specific tags, will not all convert losslessly, and you should plan a review pass for the documents that fail validation. But that is a bounded, auditable task. Run the transform, let the schema reject what does not fit, and triage the rejects. Once the content is structured, every future redesign is a component-map change rather than another migration, which is the entire point of paying the conversion cost once.

How rich-text storage and editing compare across headless platforms

FeatureSanityContentfulStrapiWordPress headless
Rich-text storage formatPortable Text: an open JSON array of typed blocks, spans, and marks. Presentation deferred to render time, not stored.Rich Text field stored as a structured JSON document, renderable via their rich-text-renderer libraries.Blocks field stores structured JSON; classic rich text stores HTML or Markdown depending on configuration.Stores HTML markup in the post body, served via WPGraphQL or REST as an HTML string to parse.
Custom block and annotation typesDefine arbitrary block types, inline objects, and annotations in your schema, each with its own typed fields and validation.Supports embedded entries and assets inside rich text; custom inline structures are more constrained than free-form blocks.Custom blocks possible via the blocks editor and components, configured in the admin rather than shipped as code.Gutenberg blocks exist but are tied to WordPress rendering; custom blocks mean PHP and block-editor plugin work.
Querying inside the proseGROQ reaches into the array: project plain text, count typed blocks, dereference link annotations with -> in one round trip.GraphQL returns the rich-text JSON, but filtering or projecting inside the document body is limited; parse client-side.REST/GraphQL return the field; querying within block content is not a first-class operation, done in application code.Body is an HTML string; querying inside it means scraping or regex, with no structured field access.
Authoring editorPortable Text Editor inside Sanity Studio, a React app you ship; toolbar, custom inputs, and allowed shapes defined in code.Hosted rich-text editor with a fixed feature set; you configure within their UI rather than ship your own editor.Open-source admin with a configurable blocks editor; deeper customization via plugins and React components.Gutenberg block editor, highly capable for WordPress but coupled to WordPress rendering assumptions.
Multi-channel renderingOne array, many serializers: @portabletext/react, to-html, plain text for email or LLM context, each with its own component map.Official renderers for web frameworks; reusing the same content for non-web targets requires custom serialization.Render the JSON yourself per channel; community renderers exist but vary by framework and field type.Optimized for HTML output; non-web channels require stripping and re-tagging the markup string.
Migration from legacy markupHTML/Markdown to Portable Text tooling parses to a tree and maps to typed blocks; validate against schema, triage failures.Import tooling and APIs exist; mapping legacy HTML into their rich-text format still needs a custom transform script.Import via API; converting HTML into the blocks format requires writing your own mapping logic.Already HTML, so no conversion, but you inherit all the presentation-coupled markup you were trying to escape.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.