Rendering Sanity Portable Text in React with Custom Serializers
Portable Text stores rich content as JSON blocks. Render it in React using block-content-to-react and custom serializers instead of raw HTML.
05 Sept 2025, 23:18 UTC

The problem: your rich text arrived as JSON, not HTML
You add a rich-text field to a Sanity schema, editors publish articles in Studio, and your frontend fetches the document. The response does not contain an HTML string. It contains a Portable Text array: a list of block objects, each with children spans and optional marks. If you try to inject it with dangerouslySetInnerHTML, there is nothing to inject. You need a serializer that turns those blocks into React elements.
What Portable Text actually stores
Portable Text is Sanity's JSON-based rich-text format. A block content field stores an array where each item is typically a block of type block. Inside, children holds span objects with the actual text, and marks holds formatting such as strong, em, or custom link annotations. The schema defines which decorators and annotations are available, but the stored document stays agnostic to your frontend components.
{
_type: "block",
style: "h2",
children: [
{ _type: "span", text: "Visit our ", marks: [] },
{ _type: "span", text: "documentation", marks: ["link"] }
],
marks: [
{ _type: "link", href: "https://example.com/docs" }
]
}
Run this GROQ query in your Sanity client or API playground to inspect the shape:
*[_type == "post"][0] {
title,
body {
...,
children[] {
...,
marks[]
}
}
}
Run it in the Sanity Studio Vision plugin or against your API endpoint with a valid read token. Expected check: the response returns body as an array of blocks, not a single HTML string. Risk: querying the full document without projecting can return large binary fields or unrelated metadata, increasing payload size.
Rendering blocks in React with custom serializers
Install the official converter in your React application:
npm install @sanity/block-content-to-react
Run this in your frontend project directory. No special permissions are required beyond standard npm access. Import BlockContent and pass your Portable Text value along with serializers for custom components.
Worked example: suppose your Sanity schema defines an annotation called link and a custom block called callout. In React:
import { BlockContent } from "@sanity/block-content-to-react";
const serializers = {
marks: {
link: ({ mark, children }) => (
{children}
),
},
blocks: {
callout: ({ children }) => (
{children}
),
},
};
export default function PostBody({ body }) {
return ;
}
Placeholders: replace body with the Portable Text field from your query result. Expected check: links render as anchor tags and callout blocks render as <aside> elements. Risk: if a mark or block type is missing from serializers, the package falls back to default rendering or skips it, so unhandled custom types can silently disappear from the page.
Trade-offs and pre-ship checks
- Vendor specificity: Portable Text is Sanity-specific. Moving to another CMS requires transforming the JSON array or writing a custom serializer, so treat the format as a coupling point, not a neutral standard.
- XSS surface: Rendering untrusted Portable Text without sanitization can introduce cross-site scripting, especially if custom marks allow arbitrary HTML attributes. Validate URLs and escape attributes before inserting them into JSX.
- Payload size: Deeply nested blocks and large documents increase JSON payload and client rendering complexity compared to a simple HTML field.
Practical verification: after deploying, open a published post in your frontend, open browser DevTools, and confirm the rich text area contains actual DOM elements rather than an escaped string. Then query the same document in the Sanity API and confirm the stored value remains an array of blocks. If both match, your serializer is mapping the stored JSON correctly.
Closing checklist
- Define the Portable Text field in your Sanity schema and add sample content in Studio.
- Query the field with GROQ and verify the response is a blocks array.
- Install
@sanity/block-content-to-reactand register serializers for every custom mark and block your editors use. - Sanitize any dynamic attributes such as link hrefs before rendering.
- Render the component in a test route and inspect the DOM output.
Portable Text keeps your content structured and portable, but the frontend still owns the presentation. The serializer layer is where that contract becomes visible HTML.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.