Sanity

Mastering Portable Text in React: Rendering Rich Content from Sanity

Portable Text is Sanity's structured, JSON-based rich text format. Learn how to render it in React with @portabletext/react, build custom components, and apply styling strategies for production-ready content.

June 26, 202612 min readMuhammad Zohaib Ramzan
A developer working with Portable Text and React in a code editor

What Is Portable Text and Why Does Sanity Use It?

When you write content in Sanity Studio, your rich text isn't stored as HTML. Instead, Sanity uses Portable Text — an open specification for structured, serializable rich text represented as JSON. Rather than a blob of markup, every heading, paragraph, link, and inline image is a discrete, typed object in an array.

This approach has significant advantages for modern content platforms:

  • Portability: The same content can be rendered as HTML, Markdown, plain text, or any custom format without re-authoring.
  • Extensibility: You can define custom block types (callouts, code blocks, embeds) that carry structured data, not just raw HTML.
  • Queryability: Because it's JSON, you can query, transform, and validate content programmatically.
  • Future-proofing: Decoupling content from presentation means your content survives framework migrations.

Sanity adopted Portable Text as its canonical rich text format precisely because it aligns with the headless CMS philosophy: content as data, presentation as code.

How Sanity Stores Rich Text as Structured JSON

At its core, a Portable Text document is an array of block objects. Each block has a _type, a style, a children array of spans, and a markDefs array for annotations like links.

Here's a minimal example of what Sanity returns from the API for a simple paragraph with a bold word:

[ { "_type": "block", "_key": "abc123", "style": "normal", "markDefs": [], "children": [ { "_type": "span", "text": "Hello, ", "marks": [] }, { "_type": "span", "text": "world", "marks": ["strong"] }, { "_type": "span", "text": "!", "marks": [] } ] } ]

A heading looks identical except style is "h2" or "h3". A link annotation lives in markDefs and is referenced by key from a span's marks array. Custom block types — like an inline image or a callout — appear as sibling objects in the top-level array with their own _type. Understanding this structure is essential before you write a single line of rendering code.

Installing and Setting Up @portabletext/react

The official library for rendering Portable Text in React is @portabletext/react. It handles traversal of the block array and delegates rendering to components you provide.

Installation

In your Next.js project, install the package and the Sanity image URL builder:

npm install @portabletext/react @sanity/client @sanity/image-url

Fetching the Body Field

In a Next.js App Router page, fetch your post and pass the body field directly to the renderer:

// app/posts/[slug]/page.tsx
import { client } from '@/sanity/client';
import { PortableText } from '@portabletext/react';

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{ title, body }`;

export default async function PostPage({ params }) {
const post = await client.fetch(POST_QUERY, { slug: params.slug });
return (
<article>
<h1>{post.title}</h1>
<PortableText value={post.body} />
</article>
);
}

With zero configuration, <PortableText> renders standard blocks (paragraphs, headings, lists, bold, italic) using default HTML elements. That's often enough to get started — but production apps almost always need custom components.

Basic Rendering with the <PortableText> Component

The <PortableText> component accepts two key props: value (the Portable Text array from Sanity) and components (an object mapping block types, marks, and list types to your own React components). The components prop follows a well-defined shape with keys for block, marks, list, listItem, and types (for custom block types).

const components = {
block: {
h2: ({ children }) => <h2 className="text-3xl font-bold mt-8 mb-4">{children}</h2>,
h3: ({ children }) => <h3 className="text-2xl font-semibold mt-6 mb-3">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic">{children}</blockquote>
),
},
marks: {
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
link: ({ value, children }) => (
<a href={value.href} className="text-blue-600 underline">{children}</a>
),
},
list: {
bullet: ({ children }) => <ul className="list-disc pl-6 my-4">{children}</ul>,
number: ({ children }) => <ol className="list-decimal pl-6 my-4">{children}</ol>,
},
types: { /* custom block types go here */ },
};

Creating Custom Components

This is where Portable Text really shines. You can define rich, interactive components for any custom block type your schema defines.

Inline Images

If your Portable Text schema includes an image type, you'll need a custom renderer. Use @sanity/image-url to build optimized URLs and Next.js's <Image> component for automatic optimization:

// components/portable-text/ImageBlock.tsx
import imageUrlBuilder from '@sanity/image-url';
import { client } from '@/sanity/client';
import Image from 'next/image';

const builder = imageUrlBuilder(client);

export function ImageBlock({ value }) {
if (!value?.asset?._ref) return null;
const imageUrl = builder.image(value).width(800).auto('format').url();
return (
<figure className="my-8">
<Image src={imageUrl} alt={value.alt ?? ''} width={800} height={450}
className="rounded-lg w-full" />
{value.caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
);
}

Code Blocks

For syntax-highlighted code, pair a custom block type with a library like react-syntax-highlighter or shiki. Your Sanity schema would define a code object type with language, code, and optional filename fields:

// components/portable-text/CodeBlock.tsx
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

export function CodeBlock({ value }) {
return (
<div className="my-6 rounded-lg overflow-hidden">
{value.filename && (
<div className="bg-gray-800 text-gray-400 text-xs px-4 py-2">
{value.filename}
</div>
)}
<SyntaxHighlighter language={value.language ?? 'text'} style={oneDark}
customStyle={{ margin: 0, borderRadius: 0 }}>
{value.code}
</SyntaxHighlighter>
</div>
);
}

Callout / Note Blocks

Callouts are a great example of structured custom blocks — they carry semantic data beyond just text. A callout block in your schema might have a tone field (info, warning, danger, success) and a text field:

// components/portable-text/Callout.tsx
const toneStyles = {
info: 'bg-blue-50 border-blue-400 text-blue-900',
warning: 'bg-yellow-50 border-yellow-400 text-yellow-900',
danger: 'bg-red-50 border-red-400 text-red-900',
success: 'bg-green-50 border-green-400 text-green-900',
};

export function Callout({ value }) {
const styles = toneStyles[value.tone] ?? toneStyles.info;
return (
<aside className={`border-l-4 rounded-r-lg p-4 my-6 ${styles}`}>
<p className="m-0">{value.text}</p>
</aside>
);
}

// Register all custom types:
// types: { image: ImageBlock, code: CodeBlock, callout: Callout }

Styling Strategies

There's no single right way to style Portable Text output. Here are the three most common approaches used in production Next.js applications.

Tailwind Typography (Prose Classes)

The @tailwindcss/typography plugin provides a prose class that applies sensible typographic defaults to any HTML content. Wrap your <PortableText> output:

<article className="prose prose-lg prose-slate max-w-none dark:prose-invert">
<PortableText value={post.body} components={components} />
</article>

This is the fastest path to readable typography. You can still override individual elements via the components prop when you need custom behaviour.

CSS Modules

For scoped, component-level styles, CSS Modules work well. Define your styles in a module file and apply the wrapper class to a div surrounding the renderer:

/* PostBody.module.css */
.body h2 { font-size: 1.75rem; margin-top: 2rem; }
.body p { line-height: 1.75; margin-bottom: 1rem; }
.body code { background: #f3f4f6; padding: 0.2em 0.4em; border-radius: 4px; }

/* In your component: */
import styles from './PostBody.module.css';
<div className={styles.body}>
<PortableText value={post.body} />
</div>

Global Stylesheet

For simpler setups, a global stylesheet targeting a wrapper class is perfectly valid and easy to maintain. This is especially useful when you don't control the component tree deeply or when migrating from a CMS that previously output HTML.

Best Practices and Gotchas

Always Provide a Components Map in Production

The default renderer uses bare HTML elements with no classes. This is fine for prototyping, but production apps should always define explicit components to control markup and styling. Relying on defaults makes it harder to enforce design system consistency.

Guard Against Missing Asset References

Custom image blocks can arrive without an asset._ref if the editor saved an incomplete block. Always guard at the top of your component:

if (!value?.asset?._ref) return null;

Memoize Your Components Object

Defining the components object inline causes React to re-render the entire Portable Text tree on every parent render. Define it outside the component or wrap it in useMemo:

// Outside the component (preferred for static configs):
const components = { block: { h2: ... }, types: { image: ImageBlock } };

// Or inside with useMemo (when components depend on props/state):
const components = useMemo(() => ({ types: { image: ImageBlock } }), []);

Use @portabletext/toolkit for Transformations

Need to extract all links, images, or plain text from a Portable Text value? The @portabletext/toolkit package provides utilities like toPlainText without rendering to HTML — useful for generating reading time estimates, search indexes, or social previews:

import { toPlainText } from '@portabletext/toolkit';

const plainText = toPlainText(post.body);
const wordCount = plainText.split(/\s+/).length;
const readingTime = Math.ceil(wordCount / 200); // ~200 wpm

Handle Unknown Block Types Gracefully

If your schema evolves and introduces new block types, unknown types will silently render nothing by default. Add a fallback to surface missing renderers during development using the unknownType key in your components map. This prevents silent content gaps from reaching production unnoticed.

Never Use dangerouslySetInnerHTML with Portable Text

Avoid converting Portable Text to an HTML string and injecting it via dangerouslySetInnerHTML. You lose React's hydration guarantees, XSS protection, and the ability to use interactive components inside your content. Always render through @portabletext/react.

Conclusion

Portable Text is one of Sanity's most powerful features, and @portabletext/react makes it straightforward to render in any React application. By understanding the underlying JSON structure, you can build custom components that go far beyond what HTML alone can express — interactive callouts, syntax-highlighted code, optimized images, and more.

The key takeaways:

  • Portable Text is structured JSON, not HTML — treat it as data, not markup.
  • Use @portabletext/react with a well-defined components map for every project.
  • Build custom components for every non-standard block type in your schema.
  • Choose a styling strategy (Tailwind prose, CSS Modules, or global CSS) and apply it consistently.
  • Guard against edge cases: missing assets, unknown types, and inline component re-renders.

With these patterns in place, your Sanity-powered React app will render rich, structured content reliably and maintainably — no matter how complex your content model grows.