Structured Content
Portable Text
Lesson 4 of 5
What you'll learn
- Read Portable Text's block/span/markDef structure
- Understand why block content is data, not an HTML string
- Render it with
@portabletext/react, overriding marks and custom types
Open a rich-text body field in the Content Lake and you won't find HTML. You'll find Portable Text: an array of typed blocks, each holding spans of text with marks — annotations that reference definitions on the block:
// One paragraph, as stored (trimmed)
{
_type: 'block',
style: 'normal',
markDefs: [{_key: 'l1', _type: 'link', href: 'https://example.com'}],
children: [
{_type: 'span', text: 'Read the ', marks: []},
{_type: 'span', text: 'docs', marks: ['l1']},
{_type: 'span', text: ' twice.', marks: []},
],
}
Why not just store HTML? Because HTML is a rendering, and a rendering bakes in decisions you can't take back. As data, the same content can become web markup today, app screens or email tomorrow; you can query it with GROQ (pt::text(body) for plain text); and non-text things — images, code blocks, embeds — sit in the same array as first-class typed objects, not <div> soup.
Rendering with @portabletext/react
The PortableText component walks the array and calls a component per block style, mark, or custom type. You override exactly the pieces you care about:
import {PortableText, type PortableTextComponents} from '@portabletext/react'
const components: PortableTextComponents = {
types: {
codeBlock: ({value}) => <pre>{value.code}</pre>,
},
marks: {
link: ({children, value}) => (
<a href={value.href} rel="noopener">{children}</a>
),
},
}
export function Body({value}: {value: any[]}) {
return <PortableText value={value} components={components} />
}
marks handle inline annotations — the renderer looks up each span's mark key in the block's markDefs and passes the definition as value. types handle whole custom blocks like images or code. Defaults cover the basics (paragraphs, headings, lists, strong/em), so you only write components for what's yours.
Unknown types are a schema smell
If the renderer warns about an unknown type, an editor is using a block your front end never handles. Treat it as a build-time checklist: every custom type in the schema gets an entry in components.types.
The challenge is a JS model of the renderer itself: walk blocks, resolve each span's marks against markDefs, and emit Markdown — proving the "one data source, many renderings" claim in a dozen lines.
Run it. renderSpan resolves marks against the block's markDefs — links wrap in [text](href), em wraps in asterisks. Try adding a 'strong' mark to a span.
Why does Sanity store rich text as Portable Text rather than an HTML string?
Next: the editorial loop — drafts, live preview, and webhooks that keep production in sync.
Saved on this device. Sign in to sync your progress everywhere.