Architecture Note: Implementing Sanity Portable Text for Cross-Platform Content
An architecture note on Sanity Portable Text, covering minimal schema design, trust boundaries, operational checks, and strategies for cross-platform content portability.
04 Jul 2026, 08:07 UTC

The Problem: Rich Text Portability
Standard HTML or Markdown stored in a CMS often locks content into a specific rendering format, making it difficult to maintain consistency across web, mobile, and native apps. Content editors need a way to embed custom blocks—such as code snippets, callouts, or image galleries—without the CMS forcing a specific DOM structure on the frontend.
The takeaway is to use Portable Text: a JSON-based specification that treats rich text as an array of structured objects rather than a monolithic string. This decouples the content's meaning from its visual representation.
Smallest Suitable Design
In Sanity v3, the minimal design for portable content is an array field. Instead of a single string, the content is stored as a sequence of blocks. Each block is a JSON object identifying its type and properties.
A minimal schema requires two components:
- The Block Type: The built-in
blocktype handles standard text, headings, and lists. - Custom Object Types: User-defined objects (e.g., a
codeblock) that allow editors to input structured data alongside text.
This design ensures that the data remains a standard JSON array, making it compatible with any language or framework capable of parsing JSON.
Trust and Data Boundaries
Portable Text is treated as untrusted user-generated content. While the Sanity Studio provides a controlled environment, the data stored in the dataset is essentially a JSON payload that could be manipulated via the API.
The trust boundary exists at the Frontend Serializer. The consuming application must:
- Whitelist Types: Only render
_typevalues explicitly defined in the frontend's serialization map. - Sanitize Inputs: If a custom block allows raw HTML or URLs, these must be sanitized using a library like DOMPurify before being inserted into the DOM to prevent Cross-Site Scripting (XSS) attacks.
Operational Checks
To maintain stability as the schema evolves, implement these three checks:
- Startup Schema Validation: Ensure the Sanity Studio validates that all types listed in the
ofarray exist. If a type is removed from the schema but remains in the content, the Studio will flag the discrepancy. - Nesting Depth Limits: To prevent recursive rendering loops or stack overflows, enforce a maximum depth (e.g., 5 levels) via a custom validation function on the array field.
- Unknown Type Logging: In the frontend renderer, implement a fallback that logs a warning to a monitoring service (like Sentry) whenever a
_typeis encountered that has no matching serializer. This identifies "schema drift" where the CMS has been updated but the frontend has not.
Failure Modes
- Missing Serializers: If a custom block (e.g.,
type: 'callout') is added to the CMS but not the frontend, the content simply disappears from the page. The operational logging mentioned above is the primary mitigation. - Payload Bloat: Documents with thousands of blocks can exceed the 5MB API payload limit or cause browser lag during rendering.
- Malformed JSON: While rare in Sanity, corrupted documents can lead to parsing errors. The frontend should wrap the rendering logic in an Error Boundary to prevent the entire page from crashing.
Design-Change Conditions
The current array-based design should be reconsidered if:
- Real-time Collaborative Text Editing: If the project requires Google Docs-style simultaneous character-level editing, a specialized CRDT-based editor (like Slate or TipTap) may be necessary.
- Complex Server-Side Dependency Resolution: If custom blocks require heavy server-side processing or data fetching that cannot be handled via GROQ projections, a custom serialization pipeline may be required before the data reaches the client.
Implementation Example
Define a post document with a body field that supports standard text and a custom code block in your schema file:
export default {
name: 'post',
type: 'document',
fields: [
{
name: 'body',
title: 'Body',
type: 'array',
of: [
{ type: 'block' },
{
name: 'code',
type: 'object',
fields: [
{ name: 'language', type: 'string' },
{ name: 'code', type: 'text' }
]
}
]
}
]
}
Deployment: Run sanity deploy from the Studio directory. This requires a CLI token with write permissions for the target dataset.
Verification Steps
- Data Entry: Create a post in the Studio. Add a paragraph and a
codeblock withlanguage: "javascript". - API Check: Run the following GROQ query in the Sanity Vision plugin to verify the JSON structure:
Ensure the*[_type == "post"]{ body }bodyis an array containing objects with_type: "block"and_type: "code". - Frontend Rendering: Using
@portabletext/react, map thecodetype to a component:
Verify that the code block renders as HTML and that missing types are handled by the fallback logger.const components = { types: { code: ({ value }) => ( <pre><code className={value.language}>{value.code}</code></pre> ) } }
Limitations and Practical Check
To verify if your documents are approaching the 5MB payload limit, inspect the Content-Length header in the browser's Network tab when fetching data from https://<projectId>.api.sanity.io/v1/data/query/<dataset>.
If the payload is too large, use GROQ projections to slice the array: body[0..50] to fetch only the first 50 blocks, or split the content into multiple referenced documents.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.