
How to Render Portable Text in React
To render Portable Text in React, install @portabletext/react, fetch the Portable Text field from Sanity, and pass it to the PortableText component as value. With no other setup it outputs sensible HTML for paragraphs, headings, lists, bold, italics and links. To control the markup or render your own content types, pass a components object with keys for block (paragraph and heading styles), marks (decorators and annotations), list and listItem, and types (custom objects such as images). The component works in both React Server Components and client components, so it fits naturally into a Next.js App Router page.
Portable Text stores rich text as JSON, which means your front end decides what each piece becomes. That's powerful, but it also means you need a renderer. This guide shows the complete setup: installing the library, querying the field, rendering it in a Next.js page, customising every kind of node, rendering Sanity images, handling unknown types, adding heading anchors and typing it all with TypeScript. If you're new to the format itself, start with what is Portable Text in Sanity.
Install the Library
@portabletext/react is the official React renderer. It replaced the older @sanity/block-content-to-react, which you should no longer use.
npm install @portabletext/react
If you're rendering Sanity images inside your rich text, install the image URL builder too:
npm install @sanity/image-url
In a Next.js project using next-sanity, you may already have these. next-sanity re-exports PortableText from @portabletext/react, so either import works.
Fetch the Portable Text Field
Your Portable Text lives in an array field, often called body or content. Query it with GROQ, expanding anything your renderer will need, such as image asset data:
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity';
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
body[]{
...,
_type == "image" => {
...,
"dimensions": asset->metadata.dimensions
}
}
}`);
The ... keeps every field on each block, and the conditional projection adds image dimensions only to image objects. Expanding at query time keeps your components simple. The GROQ projections guide explains the syntax.
The client is a normal next-sanity client:
// src/sanity/client.ts
import { createClient } from 'next-sanity';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2025-02-19',
useCdn: true,
});
Basic Rendering in a Next.js Page
In the App Router, a page is a Server Component by default, and PortableText renders fine there:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { PortableText } from '@portabletext/react';
import { client } from '@/sanity/client';
import { POST_QUERY } from '@/sanity/queries';
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await client.fetch(POST_QUERY, { slug });
if (!post) notFound();
return (
<article className="prose">
<h1>{post.title}</h1>
{Array.isArray(post.body) && <PortableText value={post.body} />}
</article>
);
}
The Array.isArray check avoids errors when a post has no body yet. Without custom components, the output is plain semantic HTML:
<h2>Getting started</h2>
<p>Sanity is a <strong>headless</strong> CMS. Read the <a href="https://www.sanity.io">docs</a>.</p>
<ul><li>First</li><li>Second</li></ul>
No styling is applied, so wrap the output in a container you can style, or pass your own components. Styling with utility classes is covered separately in styling Portable Text output with Tailwind CSS.
Customising Components
The components prop is merged with the defaults, so you only provide what you want to change. Define the object outside your component so it isn't recreated on every render:
// src/components/portable-text-components.tsx
import type { PortableTextComponents } from '@portabletext/react';
export const components: PortableTextComponents = {
block: {
normal: ({ children }) => <p className="leading-7">{children}</p>,
h2: ({ children }) => <h2 className="mt-10 text-2xl font-bold">{children}</h2>,
h3: ({ children }) => <h3 className="mt-8 text-xl font-semibold">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 pl-4 italic">{children}</blockquote>
),
},
marks: {
code: ({ children }) => <code className="rounded px-1">{children}</code>,
link: ({ value, children }) => {
const href: string = value?.href ?? '#';
const external = href.startsWith('http');
return (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
>
{children}
</a>
);
},
},
list: {
bullet: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
number: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
},
listItem: {
bullet: ({ children }) => <li className="mt-1">{children}</li>,
},
};
Then pass it in:
<PortableText value={post.body} components={components} />
Here's what each key controls:
| Key | Matches | Props you'll use |
|---|---|---|
block | A text block's style, such as normal, h2, blockquote | children, value |
marks | Decorators like strong, em, code, and annotations like link | children, value (annotations), markType |
list | A group of list items by listItem, such as bullet or number | children, value |
listItem | Individual list items by listItem | children, value, index |
types | Custom objects by _type, block-level or inline | value, isInline, index |
hardBreak | Line breaks inside a span | Pass false to keep \n |
You can also set block, list or listItem to a single component instead of an object, which then handles every style of that kind.
Decorators and annotations both live under marks. For a decorator, the component gets the decorator's name as markType. For an annotation, it also gets value, which holds the annotation's data from markDefs. Internal links, footnotes and other annotations are covered in how to add inline annotations and links in Portable Text.
Rendering Sanity Images
Images, and any other non-text objects, are rendered through types, keyed by _type. Set up the image URL builder once:
// src/sanity/image.ts
import { createImageUrlBuilder } from '@sanity/image-url';
import { client } from './client';
const builder = createImageUrlBuilder(client);
export const urlFor = (source: Parameters<typeof builder.image>[0]) =>
builder.image(source);
Then add an image component:
// src/components/portable-text-components.tsx
import Image from 'next/image';
import type { PortableTextComponents } from '@portabletext/react';
import { urlFor } from '@/sanity/image';
export const components: PortableTextComponents = {
// ...block, marks and lists from above
types: {
image: ({ value }) => {
if (!value?.asset) return null;
const width = 1200;
const ratio = value.dimensions?.aspectRatio ?? 16 / 9;
return (
<figure className="my-8">
<Image
src={urlFor(value).width(width).fit('max').auto('format').url()}
alt={value.alt ?? ''}
width={width}
height={Math.round(width / ratio)}
/>
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
);
},
},
};
Two details matter here. First, return null when the asset is missing, because an editor may have added an image block without uploading anything yet. Second, using the dimensions from the query gives next/image correct width and height, preventing layout shift. To use next/image with Sanity's CDN, add cdn.sanity.io to images.remotePatterns in next.config.ts.
The full builder API is in using Sanity's image URL builder.
Inline Objects
Objects can appear inside a text block, mixed in with spans. They're rendered by the same types components, with isInline set to true:
types: {
emoji: ({ value, isInline }) =>
isInline ? <span aria-label={value.label}>{value.symbol}</span> : null,
},
Use isInline whenever a type is allowed both as a block and inline, so you can return a span in one case and a div or figure in the other.
Handling Unknown Types
When the renderer meets a type, style or mark with no matching component, it renders a fallback and logs a console warning. That's useful in development, because it tells you an editor has used something your front end doesn't support yet.
You can customise the fallbacks:
const components: PortableTextComponents = {
unknownType: ({ value }) =>
process.env.NODE_ENV === 'development' ? (
<pre className="text-red-600">Unknown block type: {value._type}</pre>
) : null,
unknownMark: ({ children }) => <span>{children}</span>,
};
And control the warnings with onMissingComponent:
<PortableText
value={post.body}
components={components}
onMissingComponent={(message, { type, nodeType }) => {
console.error(`Missing Portable Text component: ${nodeType} ${type}`);
}}
/>
Pass onMissingComponent={false} to silence the warnings entirely, though logging them to your error tracker is usually more helpful.
Adding Heading Anchors With toPlainText
The library exports toPlainText(), which turns blocks into a string. Use it to give headings an id for a table of contents or deep links:
import { toPlainText, type PortableTextComponents } from '@portabletext/react';
const slugify = (text: string) =>
text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
export const components: PortableTextComponents = {
block: {
h2: ({ children, value }) => (
<h2 id={slugify(toPlainText(value))}>{children}</h2>
),
},
};
toPlainText() also works for meta descriptions:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { toPlainText } from '@portabletext/react';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await client.fetch(POST_QUERY, { slug });
return {
title: post?.title,
description: post?.body ? toPlainText(post.body).slice(0, 160) : undefined,
};
}
If you only need the text and not the blocks, pt::text(body) in GROQ does the same on the server and sends less data.
Typing With TypeScript
PortableTextComponents types your components object. For the value, the library exports a PortableTextBlock type:
import { PortableText, type PortableTextBlock } from '@portabletext/react';
import { components } from './portable-text-components';
export function Body({ value }: { value: PortableTextBlock[] }) {
return <PortableText value={value} components={components} />;
}
If you use Sanity TypeGen, the generated query types flow straight into PortableText, and the library's InferValue and InferStrictComponents helpers can infer which custom types and marks your components must handle. With strict inference, TypeScript tells you when the schema gains a block type that your renderer doesn't cover.
A Reusable Wrapper Component
Most projects end up with one wrapper used everywhere Portable Text appears:
// src/components/rich-text.tsx
import { PortableText, type PortableTextBlock } from '@portabletext/react';
import { components } from './portable-text-components';
export function RichText({
value,
className,
}: {
value?: PortableTextBlock[] | null;
className?: string;
}) {
if (!value?.length) return null;
return (
<div className={className}>
<PortableText value={value} components={components} />
</div>
);
}
Now every page uses the same components, and adding support for a new block type is a one-line change. When you add custom types such as callouts or embeds, see adding custom blocks to Sanity Portable Text.
FAQ: Rendering Portable Text in React
Use @portabletext/react. It's the official renderer and replaces the deprecated @sanity/block-content-to-react. next-sanity re-exports its PortableText component, so you can import it from either package.
Yes. The package ships a build for the react-server condition, so it renders in Server Components without configuration, and it also works in files marked with use client.
The renderer has no component for that _type. Add a component with the same name under components.types. Check that the name matches the schema exactly, including case.
Add an image component under components.types that builds a URL with @sanity/image-url, for example urlFor(value).width(1200).auto('format').url(). Return null if value.asset is missing.
Override the link mark. Check whether value.href starts with http, and if so add target="_blank" and rel="noopener noreferrer" to the anchor element.
Define it at module level where possible. The library recommends keeping the object referentially stable, so if it depends on props, wrap it in useMemo inside a client component.
Conclusion
Rendering Portable Text in React takes one component and one configuration object. Fetch the field with a GROQ query that expands what you need, pass it to PortableText, and override block, marks, list, listItem and types only where the defaults don't suit you. Server Components support means you can render rich text on the server in the App Router with no extra setup.
Wrap the setup in a reusable component, render images with the URL builder, handle unknown types deliberately, and lean on TypeScript to catch gaps between your schema and your renderer. From there, every new block type your editors need is just one more entry in the components map.


