Implementing Portable Text Fields in Sanity and Rendering Them in React
Define a Portable Text field in your Sanity schema, then render it safely in React with custom block serializers for text, marks, and images.
28 May 2026, 00:37 UTC

Sanity stores rich text as Portable Text, an array of JSON objects rather than an HTML string. That structure keeps content portable, but it also means a React frontend needs an explicit mapping layer. If your schema allows block styles or annotations that your renderer does not recognize, the content is dropped or flattened to plain text. The task below connects a Portable Text field in Studio to a React component that renders paragraphs, headings, marks, and images without silent data loss.
Desired outcome
You will have a document schema with a rich-text field, a GROQ query that returns the field, and a React component that converts the Portable Text array into DOM elements using a serializer object. The result should preserve headings, bold and italic marks, inline code, and image blocks.
Prerequisites
- An existing Sanity project with a Studio you can run locally.
- A React application where the content will be displayed.
- Node.js with npm or yarn available in both project directories.
- Basic familiarity with schema definitions and React function components.
Add the Portable Text field to your schema
In your Studio schema directory, define the rich-text field as an array whose members are block objects. If editors need to insert images or other custom objects inline, add those types to the of array.
// schemas/post.js
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' }
]
}
]
}
Run the following command in the Studio root directory:
sanity start
Required permissions: a local development user. Expected check: the document editor loads and the rich-text toolbar shows formatting options such as heading levels, bold, and italic. Risk: adding a custom block type without a corresponding serializer in the frontend will cause that content to be skipped during rendering.
Install the React renderer
In your React project root, install the block-content-to-react package:
npm install @sanity/block-content-to-react
If you are using TypeScript, confirm that @types/react is present in your dependencies. Installing the package inside the Studio directory instead of the frontend directory is a common mistake that leads to unresolved imports.
Map content with serializers
A serializer is a plain object that tells the renderer which React component to use for each Portable Text node type, block style, or mark. Without serializers, the renderer falls back to minimal defaults and may omit custom styles entirely.
// serializers.js
import React from 'react'
const serializers = {
types: {
block: (props) => {
switch (props.node.style) {
case 'h1':
return <h1>{props.children}</h1>
case 'h2':
return <h2>{props.children}</h2>
case 'blockquote':
return <blockquote>{props.children}</blockquote>
default:
return <p>{props.children}</p>
}
},
image: (props) => (
<img
src={props.node.asset.url}
alt={props.node.alt || ''}
loading="lazy"
/>
)
},
marks: {
strong: (props) => <strong>{props.children}</strong>,
em: (props) => <em>{props.children}</em>,
code: (props) => <code>{props.children}</code>
}
}
export default serializers
In the example above, the image serializer reads props.node.asset.url. That property is only populated if your GROQ query resolves the asset reference. If you query the raw body array without projecting the URL, the asset object contains a reference ID rather than a usable URL. In that case, use Sanity’s image URL builder to construct the CDN URL from the reference, and consider adding width and height parameters to avoid downloading oversized originals.
Query and render the document
Fetch the document in your React data layer. A simple GROQ query returns the Portable Text array as JSON:
*[_type == "post" && slug.current == $slug][0] {
title,
body
}
Pass the result to the renderer:
// PostBody.js
import React from 'react'
import BlockContent from '@sanity/block-content-to-react'
import serializers from './serializers'
export default function PostBody({ body }) {
return <BlockContent blocks={body} serializers={serializers} />
}
Where to run: the query runs in your frontend’s data-fetching layer (for example, a Sanity client call in getStaticProps or a useEffect hook). Expected check: the component receives an array of objects and renders without throwing.
Expected checks
- Studio verification: open the document editor, insert a heading, a paragraph with bold text, an inline code span, and an image block. Save the document. The stored JSON should contain
_type: "block"entries withstylevalues andmarkDefsormarkson child spans. - DOM verification: in the browser, inspect the rendered output. Paragraphs should appear as
<p>elements, bold text as<strong>, inline code as<code>, and images as<img>tags. If an element is missing, the corresponding style or mark is likely not covered by your serializer. - Console verification: the browser console should not contain warnings about unknown block types or marks. If it does, extend the
typesormarksobject in your serializer.
Recovery options and limitations
Missing content after rendering: compare the JSON stored in Studio with the serializers in your React project. Every custom style, mark, and block _type needs a matching key. Unknown nodes are skipped silently.
Broken images: if props.node.asset.url is undefined, your query did not resolve the reference. Either project the URL in GROQ or build it with the image URL builder in the serializer. Clearing the CDN cache is only necessary if you replaced an image asset and the old URL is still cached; new documents do not require a cache purge.
Schema rollback: if the Studio fails to load after you add a new block type, remove the type from the of array, run sanity start, and confirm the schema compiles before re-adding it. Because schema changes affect the Studio and the API, keep the field definition under version control.
Limitations: Portable Text is not HTML. Do not mutate the raw array with string operations or direct object edits in the Studio; use the editor interface to preserve validation. The renderer does not execute arbitrary HTML inside text nodes, which is a security benefit, but it also means you cannot paste raw HTML into the editor and expect it to render as markup. For embeds or third-party widgets stored as custom block types, validate and sanitize any external URLs or payloads before rendering them.
A practical way to check the result
Create a single test document that contains one instance of every block style, mark, and image your schema supports. Render that document in a local React route and inspect the DOM with browser developer tools. For automated coverage, write a unit test that imports BlockContent, passes a hardcoded Portable Text array, and asserts that the rendered tree contains the expected tags. This catches serializer mismatches before they reach production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.