Portable Text in Sanity: From Schema to Frontend – A Practical Guide
Portable Text lets you store rich text in a structured, platform‑agnostic way. This guide walks through schema setup, GROQ querying, and rendering with a React serializer, plus trade‑offs and a practical checklist to get you started.
10 Aug 2025, 22:23 UTC

Why Portable Text? The Problem
When you hand a document to a CMS you usually want two things: editors can author in a WYSIWYG‑like experience, and the data stays portable so you can render it on any platform. HTML is great for the web but it locks you into one rendering target. Portable Text solves that by storing rich‑text as a structured array of blocks, spans, marks and annotations. It keeps the editorial intent while letting you choose how to display it later.
Defining Portable Text in Your Schema
In Sanity you declare a Portable Text field by setting its type to array and giving it a block object as the only member of the of array. Below is a minimal schema for a blog post body, augmented with a few common marks and a custom annotation for internal links.
// schemas/post.js
export default {
name: "post",
type: "document",
fields: [
{name: "title", type: "string"},
{
name: "body",
type: "array",
of: [
{type: "block", styles: [
{title: "Normal", value: "normal"},
{title: "Heading 1", value: "h1"},
{title: "Heading 2", value: "h2"}
],
marks: {
decorators: [
{title: "Strong", value: "strong"},
{title: "Emphasis", value: "em"},
{title: "Code", value: "code"}
],
annotations: [
{name: "link", type: "object", fields: [
{name: "href", type: "url"}
]}
]
}
]
}
]
}
Notice that link is an annotation – it can be attached to any span and will be stored as a separate object in the array. By keeping the schema explicit you control exactly what formatting an editor can use, and you can add more custom annotations (e.g., a reference to a product) later.
Fetching and Inspecting the Data with GROQ
Once a document is stored, you’ll query it from your frontend. A typical GROQ query pulls the title and the body with all its children and marks. The query can also dereference references if you add them to the schema.
// In your Sanity client file
const query = `
*[_type == "post" && slug.current == $slug]{
title,
body[]{
...,
_type == "image" => {
asset->{url, "alt": alt},
hotspot,
crop
}
}
}`
const params = {slug: "my-first-post"}
const result = await sanityClient.fetch(query, params)
Run this in a Node environment with the @sanity/client package installed. You should see an array of block objects; each block has a children array of spans and a markDefs array for annotations.
Rendering Portable Text on the Frontend
Because Portable Text is not HTML, you need a serializer that turns it into React components (or any other framework). The community package @sanity/block-content-to-react is a common choice. Install it and create a simple serializer that handles the marks defined in the schema.
// src/portableText.js
import BlockContent from '@sanity/block-content-to-react'
export const PortableText = ({blocks}) => (
<BlockContent
blocks={blocks}
serializers={{
types: {
block: ({node}) => {
const Tag = node.style === 'h1' ? 'h1' : node.style === 'h2' ? 'h2' : 'p'
return <Tag>{node.children.map(renderSpan)}</Tag>
},
span: ({node}) => {
const {text, marks} = node
let element = text
marks.forEach(mark => {
if (mark === 'strong') element = <strong>{element}</strong>
if (mark === 'em') element = <em>{element}</em>
if (mark === 'code') element = <code>{element}</code>
})
return element
}
},
marks: {
link: ({children, mark}) => <a href={mark.href}>{children}</a>
}
}}
/>
)
function renderSpan(node) {
if (node._type === 'span') {
return node.children.map(renderSpan)
}
return null
}
Use it in a page component like:
import {PortableText} from '../portableText'
function PostPage({post}) {
return (
<div>
<h1>{post.title}</h1>
<PortableText blocks={post.body} />
</div>
)
}
Trade‑offs and Practical Checks
- Custom Serializer Needed – Unlike Markdown you can’t just drop the content into a
<pre>tag. You must write or adopt a serializer that matches your schema. Test by rendering a sample document locally and inspecting the DOM. - Payload Size – Each block, span, and annotation adds JSON overhead. For very long documents consider projecting only the fields you need or paginating the body.
- Schema Changes Affect Existing Content – Adding a new mark or annotation requires a migration plan if you want to preserve old documents. Use Sanity’s
exportandimporttools to back up before a schema change.
To verify that your serializer works, run a unit test that feeds a known Portable Text array and asserts the output contains the expected tags and attributes.
Getting Started Checklist
- Create a
bodyfield in your schema as shown above. - Publish a document in Sanity Studio and confirm the editor shows the block styles and marks.
- Write a GROQ query that projects
bodyand fetch it with the Sanity client. - Install
@sanity/block-content-to-react(or your chosen renderer) and render the blocks in a component. - Run a visual test: open the page, check that headings, bold, and links appear correctly.
- Measure payload size; if it’s large, consider using
body[]{..., _type!="image"}to exclude heavy assets.
With these steps you’ll have a robust, portable rich‑text solution that stays in sync with your editorial workflow and renders cleanly across any frontend framework.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.