Portable Text in Sanity: From Schema to React Render
Portable Text lets you store rich‑text as JSON in Sanity, query it with GROQ, and render it in React using custom serializers. This guide shows the full workflow, a real example, and the size‑versus‑flexibility trade‑off.
02 Aug 2026, 17:43 UTC

Problem: Rich‑Text Needs Flexibility
When a CMS stores content as a plain string, editors lose formatting controls and developers lose structure. Sanity’s Portable Text solves this by representing rich‑text as a nested JSON array. The challenge is turning that JSON into useful data on the front‑end.
Thesis: Portable Text is a Portable, Query‑able, Render‑able Rich‑Text Solution
By combining a schema that defines block types, a GROQ query that extracts the data, and a React serializer that maps blocks to components, you get a content model that is both flexible for editors and efficient for developers.
Step 1 – Define the Field in Studio
In a Sanity v3+ project, add a field of type array that contains a block type. Extend it with custom blocks or marks as needed.
export default {
name: 'post',
title: 'Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string'
},
{
name: 'body',
title: 'Body',
type: 'array',
of: [
{type: 'block'},
{
type: 'image',
fields: [
{name: 'caption', title: 'Caption', type: 'string'}
]
},
{
type: 'code',
fields: [
{name: 'language', title: 'Language', type: 'string'},
{name: 'code', title: 'Code', type: 'string'}
]
}
]
}
]
}
After publishing a document with rich‑text, open the Vision tool and run *[_type == 'post'][0]{body} to see the JSON structure.
Step 2 – Query with GROQ
Portable Text is queried like any other field, but you can project specific parts or convert it to plain text directly in the query.
*[_type == "post" && slug.current == $slug][0] {
title,
body,
"plainText": pt::text(body)
}
Running this query in Vision or via the client will return the raw JSON array for body and a single string for plainText. The pt::text() function is handy when you need a searchable or SEO‑friendly version.
Step 3 – Render in React
The official @sanity/block-content-to-react package turns Portable Text into React elements. Provide a serializers object to handle custom block types or marks.
import BlockContent from '@sanity/block-content-to-react';
const serializers = {
types: {
image: ({node}) => (
<img src={node.url} alt={node.caption || ''} />
),
code: ({node}) => (
<pre>
<code className={`language-${node.language}`}>
{node.code}
</code>
</pre>
)
}
};
export default function Post({post}) {
return (
<article>
<h1>{post.title}</h1>
<BlockContent blocks={post.body} serializers={serializers} />
</article>
);
}
Run this component in a local dev server. Open the browser’s network tab and inspect the payload for body. You’ll notice the JSON is larger than a plain string, but it contains all the formatting data.
Trade‑off: JSON Size vs. Flexibility
- Pros: Portable across platforms, extensible with custom blocks, fine‑grained editor controls.
- Cons: Larger document size, requires client‑side serialization, extra learning curve for schema design.
To gauge the impact, compare the size of a document with Portable Text to one with a simple string field using the browser’s dev tools. The difference is typically a few kilobytes per document, acceptable for most sites but worth noting for high‑volume APIs.
Actionable Checklist
- Define a Portable Text field in your schema and publish a test document.
- Verify the JSON structure in Vision.
- Write a GROQ query that returns both the raw blocks and
pt::text(). - Set up
@sanity/block-content-to-reactin your React app and create serializers for any custom blocks. - Measure the network payload to understand the size trade‑off.
- Iterate: add or remove custom blocks as editor needs evolve.
With this workflow, you can confidently choose Portable Text for projects that need rich‑text flexibility while keeping an eye on performance.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.