Stop Rendering Portable Text, Start Querying It
Portable Text lets you model rich content as typed blocks and transform it per channel with GROQ instead of rendering HTML everywhere. Use schema-defined custom blocks and serializer maps for portable, safe content.
15 Jun 2026, 13:31 UTC

The problem is the same article in four places
Marketing wants a long-form post that lives on the web, in the mobile app, in an email digest, and as a voice summary. If the body is stored as HTML or Markdown, each channel needs its own sanitization, stripping, and re-wrapping. Editors lose control, and developers own every edge case.
Portable Text changes the contract. The body is an array of typed blocks with inline marks and annotations, not a string. That means validation happens in the schema, versioning is document-level, and the same content can be projected differently per channel without touching the editor.
Model blocks as data, not formatting
In Sanity you define what can appear in a rich field and what data each block carries. A typical post schema declares body as an array of allowed types.
// schemas/post.js
export default {
name: 'post',
type: 'document',
fields: [
{ name: 'title', type: 'string' },
{
name: 'body',
type: 'array',
of: [
{ type: 'block' },
{ type: 'callout' },
{ type: 'productReference' }
]
}
]
}
A custom block like callout is a structured object, not a styled div.
// schemas/callout.js
export default {
name: 'callout',
type: 'object',
fields: [
{ name: 'tone', type: 'string', options: { list: ['info','warning','tip'] } },
{ name: 'text', type: 'array', of: [{ type: 'block' }] }
]
}
Editors embed data. The front end decides how to render it. Images inside Portable Text reference Sanity asset documents, so you can request optimal variants with URL parameters for resize, format conversion, and focal point cropping without storing multiple copies.
Render with a serializer map, not ad-hoc parsing
The official @portabletext/react and @portabletext/vue packages provide typed serializers. You map block types and marks to components once, and the library walks the Portable Text array.
// components/PortableTextRenderer.jsx
import { PortableText } from '@portabletext/react'
const components = {
types: {
callout: ({ value }) => (
)
}
}
export default function Renderer({ value }) {
return
}
Generate TypeScript types from your Sanity schemas and the serializer map stays in sync with what the studio allows. Missing serializers silently drop annotations, so keep a single source of truth for custom marks and internal references across web, mobile, and voice consumers.
Transform at query time with GROQ
You do not need to fetch full Portable Text and then strip it in the client. GROQ can project and transform Portable Text directly.
Run the query in the Sanity API explorer or GROQ playground with a read token. The query below extracts plain text for search indexing and rewrites internal references to front-end routes.
*[_type == "post" && slug.current == $slug][0]{
title,
plain: pt::text(body),
summary: pt::serialize(body)[0...200]
}
pt::text flattens marks and void blocks to plain text. pt::serialize can produce a simplified representation for RSS feeds where unsupported marks must be stripped. Test edge cases with nested marks and void blocks in a staging dataset before using these projections in production.
Trade-offs to plan for
Portable Text JSON can become deeply nested. Large documents over ~100KB increase API payload size and degrade studio performance. Split long content into child documents referenced by weak references when appropriate.
Schema changes to block definitions are not backward compatible. Adding or removing marks or changing block types requires a migration. Use Sanity CLI migration scripts to update existing documents and validate rendering after the change.
Real-time collaboration locks at the document level. Different blocks merge automatically, but overlapping inline mark edits can create conflict markers that require manual resolution in the studio.
Actionable check
Define a Portable Text schema with one custom block, publish sample content, and inspect the raw JSON via the API. Install @portabletext/react in a Next.js app, implement a serializer map for the custom block, and compare rendering against the studio preview using Visual Editing Stega. Add a GROQ projection with pt::text to build a search index and verify the plain text matches expectations for your content.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.