Moving Beyond HTML: Engineering Rich Text with Sanity Portable Text
Stop storing rich text as opaque HTML. Learn how Sanity's Portable Text uses structured JSON and custom serializers to ensure content portability and editorial governance.
17 Sept 2025, 22:21 UTC

Editors need bold text, hyperlinks, callouts, and internal document references. Developers need those elements to be queryable, portable, and safe to render across web, mobile, and email. Storing rich text as opaque HTML in a CMS solves the immediate editorial need but creates a long-term engineering debt.
The solution is to treat rich text as structured data. In Sanity, this is achieved through Portable Text: a JSON array of blocks and spans with schema-defined marks and annotations, rendered by a serializer you control.
The Governance Problem with HTML Storage
When rich text is saved as HTML, validation lives in the editor UI and governance lives in hope. Editors can paste arbitrary markup, link to external sites that later break, or embed content that doesn't translate to a mobile app. Furthermore, you cannot reliably query for "all mentions of product X" because that data is trapped inside a string of HTML tags.
Portable Text stores content as a data structure. A block is a structural element like a paragraph, heading, or list item. Each block contains children spans containing the actual text and marks (like bold or italic) and annotations (like links or references).
Decoupling Authoring from Rendering
In Sanity schemas, a rich text field is defined as an array of type block. This allows you to configure exactly which marks and annotations are available. Instead of allowing any HTML tag, you define specific objects—such as internal document references or custom callouts—that editors can apply.
Rendering is entirely decoupled from authoring. A serializer is a function that maps these Portable Text JSON nodes to UI components. Because the storage is agnostic, the same content model can be presented as an HTML article on a website, a native view in a mobile app, or a simplified text block in an email, without modifying the source data.
Worked Example: Internal Reference Annotations
A common engineering challenge is ensuring internal links don't break when a page slug changes. A practical pattern is to use an annotation that references another Sanity document and a serializer that resolves that reference via GROQ (Sanity's query language) to render a dynamic link.
Define the annotation in your schema file:
// schemas/blockContent.js
export default {
name: 'blockContent',
type: 'array',
of: [
{
type: 'block',
marks: {
annotations: [
{
name: 'internalLink',
type: 'annotation',
title: 'Internal link',
definition: {
name: 'internalLink',
type: 'object',
fields: [
{name: 'reference', type: 'reference', to: [{type: 'page'}]}
]
}
}
]
}
},
{type: 'image'}
]
}When an editor applies this "Internal link," Sanity stores a reference ID rather than a hardcoded URL. On the frontend, your serializer receives this node and can fetch the current title and slug of the referenced page. This ensures that if the target page's URL changes, the link updates automatically across the entire site.
Verification Steps:
- Inspect your schema file to confirm the field is an array of blocks and verify the JSON output contains
_type,children, andmarks. - Create a test document in Sanity Studio and export the raw JSON to ensure the annotation stores a reference object rather than a string.
- Implement a minimal serializer in your frontend and verify that changing the referenced document's title in Studio updates the rendered link text.
Trade-offs and Technical Limitations
Custom annotations increase editorial power but can lead to "annotation sprawl." Without a defined whitelist and validation, editors may apply too many overlapping marks, complicating the serializer logic. It is recommended to enforce validation on publish to ensure references are still valid.
Portable Text schema shapes and serializer APIs can be version-sensitive across the Sanity client and @portabletext/react (or similar) packages. Major version updates may require updates to your mapping logic; pinning versions is advised.
Crucially, Portable Text does not automatically provide accessibility semantics. Because the serializer is a custom mapping, the developer is responsible for ensuring that a "heading" block renders as an <h2> or <h3> and that links include appropriate aria-labels.
Actionable Implementation
Audit your existing rich text fields. Any field where you need to enforce governance or ensure cross-platform compatibility should be migrated from a string/HTML field to a Portable Text array.
Start by implementing a single high-value annotation, such as the internalLink. Build a serializer that resolves these references and includes a graceful fallback (e.g., rendering plain text) if the target document is unpublished. Finally, document the allowed marks for your editorial team to prevent markup abuse.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.