Skip to content

Render Portable Text

Portable Text is stored as JSON. To display it, you pass the data through a serializer that converts each block into your target output: HTML strings, React components, Vue templates, Markdown, or any other format.

A Portable Text document is an array of blocks. Each block has a _type that tells the serializer what it is and how to render it.

Some blocks are text blocks (_type: "block"): paragraphs, headings, lists, and inline formatting. Serializers handle these out of the box.

But Portable Text isn’t just rich text. The same array can contain any type of structured content: images, code blocks, calls to action, videos, tables, or types you define yourself. These custom blocks sit alongside text blocks in the array, each with their own _type and data. You provide a component for each custom type, and the serializer renders them in sequence.

Within text blocks, marks add meaning to inline text. Decorators (bold, italic, underline) work by default. Annotations (links, references, footnotes) carry structured data as mark definitions. A link annotation isn’t just an <a> tag: it’s a data object with href, target, tracking parameters, or whatever fields you define. You customize annotation rendering through the marks component map. Text blocks can also contain inline objects: structured data embedded in the text flow, like a stock ticker, a product reference, or a custom emoji. Inline objects are rendered through the same types component map as custom blocks.

If a serializer encounters a type it doesn’t recognize, it skips it. Nothing breaks.

Install the serializer for your framework:

Terminal window
npm install @portabletext/react
import {PortableText} from '@portabletext/react'
function Article({content}) {
return <PortableText value={content} />
}

React guide →

That’s enough to render paragraphs, headings, lists, bold, italic, and links. But Portable Text can contain much more than formatted text. The next sections show how to render custom block types and marks.

This is where Portable Text goes beyond rich text. Custom blocks carry structured data (images, CTAs, embeds, code blocks) that the serializer doesn’t know how to render by default. You tell it what to do by mapping the block’s _type to a component.

Here’s an image block in Portable Text:

{
"_type": "image",
"_key": "abc123",
"url": "https://example.com/photo.jpg",
"alt": "A mountain landscape",
"caption": "View from the summit"
}

The _type is "image". To render it, add an image entry to the types component map:

import {PortableText} from '@portabletext/react'
const components = {
types: {
image: ({value}) => (
<figure>
<img src={value.url} alt={value.alt} />
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
),
},
}
function Article({content}) {
return <PortableText value={content} components={components} />
}

The pattern is the same in every framework: map the _type to a function that receives the block’s data as value and returns your output.

This works for any custom block type. A call-to-action, a code block, an embedded video, a table: define the _type in your schema, add a component to the types map, and the serializer handles the rest.

Marks add meaning to inline text. There are two kinds:

Decorators are simple flags like strong, em, and underline. Serializers render these by default (bold, italic, underlined text).

Annotations carry data. A link annotation looks like this in Portable Text:

{
"_type": "block",
"children": [
{"_type": "span", "text": "Read the "},
{"_type": "span", "text": "documentation", "marks": ["link1"]},
{"_type": "span", "text": "."}
],
"markDefs": [
{
"_key": "link1",
"_type": "link",
"href": "/docs",
"openInNewTab": true
}
]
}

The span with "marks": ["link1"] references the mark definition with "_key": "link1". The mark definition carries the data (href, openInNewTab). To customize how this renders, add a link entry to the marks component map:

const components = {
marks: {
link: ({children, value}) => {
const rel = !value.href.startsWith('/')
? 'noreferrer noopener'
: undefined
return (
<a
href={value.href}
target={value.openInNewTab ? '_blank' : undefined}
rel={rel}
>
{children}
</a>
)
},
},
}

Mark components receive children (the annotated text, already rendered) and value (the mark definition data). The same pattern works for any annotation type: footnotes, internal references, highlights, or custom marks specific to your schema.

All serializers use the same component map structure (except Astro, which uses singular keys type and mark):

KeyWhat it rendersWhen you need it
typesCustom block and inline objectsImages, CTAs, code blocks, embeds
marksAnnotations (marks with data)Links, footnotes, references
blockBlock stylesCustom heading styles, pull quotes
listList containersCustom bullet or numbered list wrappers
listItemList itemsCustom list item rendering
hardBreakLine breaks within textCustom line break handling

Unknown types, marks, and styles are skipped by default. You can handle them with unknownType, unknownMark, unknownBlockStyle, unknownList, and unknownListItem components.

Each framework has its own patterns for custom components, TypeScript support, and utilities:

For the complete list of serializers, converters, and community packages, see All packages.