Using Sanity Portable Text to Decouple Rich Text from Presentation
Learn how Sanity Portable Text stores rich text as JSON, enabling platform‑agnostic querying and rendering with GROQ and React.
04 Jun 2026, 04:58 UTC

The problem with HTML‑only rich text
When a CMS stores rich‑text as plain HTML, the markup is tightly coupled to a specific presentation layer. Changing the design, targeting a mobile app, or extracting plain‑text for search often requires fragile string manipulation or risky regex replacements. This coupling makes reuse across channels brittle and increases maintenance overhead.
Thesis: Portable Text as a presentation‑agnostic data model
Sanity’s Portable Text stores rich text as a JSON‑based, block‑level schema. Each piece of content becomes an array of blocks (paragraphs, headings, lists, custom types) with optional marks for inline styles such as bold, italic, or links. Because the data is structured, you can query, transform, and render it consistently for any frontend—web, React Native, or even a voice assistant—without touching the source.
Structure of Portable Text
A Portable Text value looks like this:
[
{
"_type": "block",
"style": "normal",
"markDefs": [
{
"_type": "link",
"href": "https://example.com"
}
],
"children": [
{
"_type": "span",
"text": "Visit ",
"marks": []
},
{
"_type": "span",
"text": "our site",
"marks": ["link"]
},
{
"_type": "span",
"text": ".",
"marks": []
}
]
},
{
"_type": "block",
"style": "h2",
"markDefs": [],
"children": [
{
"_type": "span",
"text": "Section heading",
"marks": []
}
]
}
]
Key concepts:
- Blocks – top‑level elements like paragraphs, headings, lists, or custom objects.
- Marks – inline annotations (bold, italic, link, custom) that apply to spans of text.
- Custom types – you can embed any Sanity schema type (e.g., a tweet, a product card) inside a block.
Querying Portable Text with GROQ
Because the field is just JSON, you can use GROQ to project exactly what you need. For a blog post type with a body Portable Text field, a query that returns the title and a plain‑text version of the body looks like this:
*[_type == "post" && slug.current == $slug][0] {
title,
body[] {
...,
children[] {
text,
marks
}
}
}
If you only need the raw text without any marks, you can flatten the children:
*[_type == "post" && slug.current == $slug][0] {
title,
plainText: body[]{
children[]{
text
}
}
}
The query runs in the Sanity Vision tool or via the CLI (sanity dataset query) and returns pure JSON that your frontend can consume.
Rendering Portable Text in React
The official @portabletext/react package turns the JSON tree into React elements. You provide a serializers object that maps block styles and mark types to components.
import PortableText from "@portabletext/react";
const serializers = {
types: {
block: ({ style, children }) => {
const Tag = style === "h2" ? "h2" : "p";
return {children};
},
span: ({ text, marks }) => {
// simple link mark handler
const isLink = marks.includes("link");
return isLink ? {text} : text;
}
}
};
function Post({ post }) {
return (
{post.title}
);
}
When you render the post, headings become h2 elements, paragraphs become p, and link marks produce actual anchor tags—all without touching the source data.
Worked example: a blog post schema
// schemas/post.js
export default {
name: "post",
title: "Blog Post",
type: "document",
fields: [
{ name: "title", title: "Title", type: "string" },
{ name: "slug", title: "Slug", type: "slug", options: { source: "title" } },
{
name: "body",
title: "Body",
type: "array",
of: [{ type: "block" }], // Portable Text
},
],
};
Create a document, fill the body with headings, lists, and a link, then publish. Run the GROQ query shown above to verify the JSON structure, and render it with the React snippet to see the heading and link appear correctly.
Trade‑off and limitation
Portable Text adds a small parsing step on the client because the JSON must be walked and turned into DOM elements. This overhead is usually negligible, but for extremely large documents (hundreds of kilobytes of rich text) you may notice a slight increase in render time. Additionally, any custom block type you define must be mirrored in both the Sanity Studio schema and the frontend serializers; otherwise the block will be ignored or rendered as a fallback.
Actionable closing
- Add a
bodyfield of typearraywith[{ type: "block" }]to your document schema. - Install the renderer:
npm i @portabletext/react. - Create a simple serializer map for the block styles and marks you use.
- Fetch the Portable Text via GROQ and pass it to
PortableTextin your React component. - Experiment with a custom block (e.g., a callout) by adding it to the
ofarray and providing a matching React component in the serializer.
By treating rich text as structured data, you gain the freedom to reuse content anywhere while keeping the editing experience familiar for content creators.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.