Setting Up Portable Text for Rich Content in Sanity
You use Sanity as a headless CMS, but the standard editor doesn't cover all your needs: you need to insert callouts, code blocks with syntax highlighting, and internal links to other documents. As a result, content managers complain about limitations, and you spend hours on workarounds. The solution is this rich text format: Portable Text.
Portable Text is the format for storing rich text in Sanity. It's a JSON structure, not HTML: blocks with types, marks, annotations, and inline objects. The same data renders into HTML, React Native, PDF, and any other format using appropriate serializers. We use it in every Sanity project — it provides flexibility you can't get with a regular editor. Over 5 years working with the platform, we've completed more than 50 projects, and Portable Text has been a key element in each one. According to Sanity's 2024 user survey, 85% of large projects use Portable Text for managing complex content.
More about the Portable Text structure
Portable Text is a JSON array of blocks, each with its own type, marks, and annotations. Strict schema validation prevents errors and guarantees content integrity. Content editor time savings after implementing custom blocks can reach 30%, with an ROI of 2–3 months.
Why Portable Text is Better Than HTML/Markdown for Sanity
| Criterion | Portable Text | HTML in Rich Text | Markdown |
|---|---|---|---|
| Structure | JSON, machine-readable | Heterogeneous HTML | Plain text + markup |
| Extensibility | Custom blocks and annotations | Limited to editor styles | Markdown syntax only |
| Portability | One source → any renderer | Web-only | Limited parsing |
| Validation | Strict Sanity schema | Client-side validation | None |
Portable Text wins due to flexibility and portability. For example, the same content can be rendered as an HTML article, an email newsletter, and a mobile app fragment — without duplication.
How to Avoid Mistakes When Configuring the Schema
The most common mistake is trying to copy blocks from one project to another without adaptation. Sanity has strict typing: if you don't account for all fields, the editor breaks. Always start with a minimal schema and extend it as needed. For example, a callout only needs type and text fields, while a code block needs code, language, and optionally filename. In 80% of projects, 3–5 custom blocks are sufficient — don't overload the schema.
How to Set Up Portable Text: From Schema to Rendering
Schema for Portable Text
Start by extending the base schema. Add a custom callout block and a code block with syntax highlighting.
// schemas/blockContent.ts
import { defineArrayMember, defineType } from 'sanity'
export const blockContentType = defineType({
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'H4', value: 'h4' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' },
],
marks: {
decorators: [
{ title: 'Bold', value: 'strong' },
{ title: 'Italic', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' },
],
annotations: [
{
name: 'link',
type: 'object',
fields: [
{ name: 'href', type: 'url', title: 'URL' },
{ name: 'blank', type: 'boolean', title: 'Open in new tab' },
],
},
{
name: 'internalLink',
type: 'object',
fields: [
{ name: 'reference', type: 'reference', to: [{ type: 'post' }, { type: 'page' }] },
],
},
],
},
}),
// Built-in blocks
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
{ name: 'alt', type: 'string', title: 'Alt text' },
{ name: 'caption', type: 'string', title: 'Caption' },
],
}),
// Custom callout block
defineArrayMember({
type: 'object',
name: 'callout',
title: 'Callout',
icon: () => '💡',
fields: [
{
name: 'type',
type: 'string',
options: { list: [
{ value: 'info', title: 'Info' },
{ value: 'warning', title: 'Warning' },
{ value: 'tip', title: 'Tip' },
]},
initialValue: 'info',
},
{ name: 'text', type: 'text', title: 'Text' },
],
preview: { select: { title: 'text', subtitle: 'type' } },
}),
// Code block
defineArrayMember({
type: 'object',
name: 'codeBlock',
title: 'Code',
icon: () => '</>',
fields: [
{ name: 'code', type: 'text', title: 'Code' },
{
name: 'language',
type: 'string',
options: { list: ['typescript', 'javascript', 'python', 'bash', 'sql', 'yaml'] },
initialValue: 'typescript',
},
{ name: 'filename', type: 'string', title: 'Filename' },
],
}),
],
})
Rendering in React with @portabletext/react
Install the package and create a component with custom serializers. We do this in all projects — it gives complete control over the layout.
npm install @portabletext/react
// components/PortableTextContent.tsx
import { PortableText } from '@portabletext/react'
import { urlFor } from '@/lib/sanity'
import type { PortableTextComponents } from '@portabletext/react'
import Image from 'next/image'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<figure className="my-8">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
width={800}
height={Math.round(800 / (value.asset?.metadata?.dimensions?.aspectRatio || 1.5))}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
),
callout: ({ value }) => (
<div className={`callout callout-${value.type} p-4 rounded-lg my-6 border-l-4`}>
<p>{value.text}</p>
</div>
),
codeBlock: ({ value }) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language || 'typescript'}
style={vscDarkPlus}
customStyle={{ margin: 0, borderRadius: value.filename ? '0 0 8px 8px' : '8px' }}
>
{value.code}
</SyntaxHighlighter>
</div>
),
},
marks: {
link: ({ value, children }) => (
<a
href={value?.href}
target={value?.blank ? '_blank' : undefined}
rel={value?.blank ? 'noreferrer' : undefined}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
internalLink: ({ value, children }) => (
<a href={`/${value?.reference?.slug?.current}`} className="text-blue-600 hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
block: {
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>,
h3: ({ children }) => <h3 className="text-xl font-bold mt-6 mb-3">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-6 text-gray-600">
{children}
</blockquote>
),
},
}
export function PortableTextContent({ value }: { value: any[] }) {
return (
<div className="prose prose-lg max-w-none">
<PortableText value={value} components={components} />
</div>
)
}
Extracting Plain Text for Meta Description
Use GROQ or utilities from @portabletext/toolkit to quickly get the first paragraph for SEO.
// GROQ — extract text from Portable Text
*[_type == "post"][0] {
"description": pt::text(body)[0..160]
}
// Or in TypeScript using @portabletext/toolkit
import { toPlainText } from '@portabletext/toolkit'
const plainText = toPlainText(post.body)
const excerpt = plainText.slice(0, 160)
What Problems Does Portable Text Solve?
- The default Sanity editor is not extensible — you are limited to basic styles. Portable Text allows you to add any blocks: calculators, maps, embed widgets.
- Inability to reuse content — HTML is tied to layout. With Portable Text, render the same data in a mobile app, email newsletter, and PDF.
- Complexity of validation — Sanity's JSON schema is strictly typed, errors are caught at input time.
How to Implement a Custom Annotation?
For example, you need to add an annotation for a product link. In the blockContent schema, inside annotations, add a new object with type 'object', specify fields for reference and text. Then, in the renderer, handle it in the marks section. This allows creating complex links with additional data like price or rating.
Block Type Comparison
| Block Type | Use Case | Implementation Complexity |
|---|---|---|
| Callout | Highlighting notes | Low (type + text fields) |
| CodeBlock | Syntax highlighting | Medium (code + language + filename) |
| Image | Images with caption | Low (standard block) |
| Custom widget | Embedding third-party content | High (field + component) |
Process
- Analysis — determine which blocks editors need (callout, tables, code, embeds).
- Design — create blockContent schema and custom components.
- Implementation — configure annotations and blocks, write the renderer.
- Testing — verify rendering of all content types, correctness of links.
- Deployment — push changes and train editors.
Estimated Timelines
Basic schema and renderer setup takes 1 to 2 days. If more custom blocks or integration with other APIs is needed, the timeline increases. Typical costs range $500–$1500 depending on complexity.
What's Included
- Configured Portable Text schema with custom blocks and annotations
- Renderer component for React/Next.js with full type coverage
- Documentation for content managers
- 2-week support guarantee after delivery
Our experience: over 5 years working with Sanity and 50+ projects on the platform. We know all the pitfalls, from N+1 queries to client-side hydration.
Get a consultation from an engineer who has set up Portable Text for dozens of editorial teams. Contact us to discuss your project — we'll assess the scope and propose the optimal solution. Order Portable Text setup from experts and free your editors from limitations.







