Mastering Sanity CMS Portable Text: A Developer’s Complete Guide
Transform your content management workflow with structured, flexible text that scales with your applications
Why Portable Text Will Change How You Handle Content
If you’ve ever struggled with rich text editors that output unpredictable HTML, or found yourself wrestling with content that breaks when you need to display it across different platforms, you’re not alone. Portable Text is Sanity’s innovative solution that puts you back in control of your content structure while giving content creators the flexibility they need.
Unlike traditional rich text that outputs HTML soup, Portable Text stores content as structured JSON. This means you can render the same content consistently across web, mobile, email, and any future platform you build.
What You’ll Learn
By the end of this tutorial, you’ll be able to:
- Set up Portable Text fields in your Sanity schema
- Customize the editor toolbar for your content team’s needs
- Render Portable Text in React, Vue, and vanilla JavaScript
- Create custom block types for advanced content structures
- Handle images, links, and custom annotations
- Optimize performance and implement best practices
Understanding Portable Text: The Foundation
The Problem with Traditional Rich Text
Traditional WYSIWYG editors give you HTML like this:
<p>Check out our <a href="/products">amazing products</a> and see why <strong>developers love</strong> our approach!</p>
<img src="hero.jpg" alt="Hero image" />
The challenge: This HTML is fragile, platform-specific, and difficult to transform for different contexts.
The Portable Text Solution
Portable Text stores the same content as structured data:
[
{
"_type": "block",
"children": [
{"_type": "span", "text": "Check out our "},
{
"_type": "span",
"text": "amazing products",
"marks": ["link-to-products"]
},
{"_type": "span", "text": " and see why "},
{
"_type": "span",
"text": "developers love",
"marks": ["strong"]
},
{"_type": "span", "text": " our approach!"}
]
},
{
"_type": "image",
"asset": {"_ref": "image-abc123"},
"alt": "Hero image"
}
]
The benefit: You have complete control over how this renders on every platform, and you can transform it for different contexts without losing meaning.
Setting Up Your First Portable Text Field
Basic Schema Configuration
Start with a simple Portable Text field in your Sanity schema:
schemas/post.js
// schemas/post.js
export default {
name: 'post',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string'
},
{
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
// Customize available styles
styles: [
{title: 'Normal', value: 'normal'},
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'Quote', value: 'blockquote'},
],
// Customize available list types
lists: [
{title: 'Bullet', value: 'bullet'},
{title: 'Number', value: 'number'}
],
// Customize available marks (inline formatting)
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
{title: 'Code', value: 'code'}
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Link',
fields: [
{
name: 'href',
type: 'url',
title: 'URL'
},
{
name: 'blank',
type: 'boolean',
title: 'Open in new tab'
}
]
}
]
}
}
]
}
]
}
Pro Tip: Start Simple, Expand Gradually
Following our commitment to customer success, we recommend starting with basic formatting options and expanding based on your content team’s actual needs. This prevents overwhelming content creators while ensuring you can always add more functionality later.
Rendering Portable Text in Your Applications
React Implementation
Install the official React renderer:
npm install @portabletext/react
Create a basic renderer:
import { PortableText } from '@portabletext/react'
const components = {
// Custom block styles
block: {
h1: ({children}) => <h1 className="text-4xl font-bold mb-4">{children}</h1>,
h2: ({children}) => <h2 className="text-3xl font-semibold mb-3">{children}</h2>,
blockquote: ({children}) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-4">
{children}
</blockquote>
),
},
// Custom marks
marks: {
link: ({children, value}) => (
<a
href={value.href}
target={value.blank ? '_blank' : '_self'}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
strong: ({children}) => <strong className="font-bold">{children}</strong>,
code: ({children}) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
}
function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<PortableText value={post.content} components={components} />
</article>
)
}
Next.js with Image Optimization
For better performance, integrate with Next.js Image component:
import Image from 'next/image'
import { urlFor } from '../lib/sanity'
const components = {
types: {
image: ({value}) => (
<div className="my-8">
<Image
src={urlFor(value).url()}
alt={value.alt || 'Blog image'}
width={800}
height={400}
className="rounded-lg"
/>
{value.caption && (
<p className="text-sm text-gray-600 mt-2 text-center">
{value.caption}
</p>
)}
</div>
),
},
// ... other components
}
Advanced Customization: Custom Block Types
Creating a Code Block Component
Enhance your content with syntax-highlighted code blocks:
schemas/codeBlock.js
// schemas/codeBlock.js
export default {
name: 'codeBlock',
title: 'Code Block',
type: 'object',
fields: [
{
name: 'language',
title: 'Language',
type: 'string',
options: {
list: [
{title: 'JavaScript', value: 'javascript'},
{title: 'TypeScript', value: 'typescript'},
{title: 'Python', value: 'python'},
{title: 'CSS', value: 'css'},
{title: 'HTML', value: 'html'},
]
}
},
{
name: 'code',
title: 'Code',
type: 'text'
},
{
name: 'filename',
title: 'Filename (optional)',
type: 'string'
}
]
}
Add it to your Portable Text field:
// In your schema
{
name: 'content',
type: 'array',
of: [
{type: 'block'},
{type: 'codeBlock'}, // Add your custom block
{type: 'image'}
]
}
Render it in React:
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/prism'
const components = {
types: {
codeBlock: ({value}) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-white px-4 py-2 text-sm rounded-t-lg">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language}
style={tomorrow}
className="rounded-b-lg"
>
{value.code}
</SyntaxHighlighter>
</div>
),
},
}
Performance Optimization Strategies
1. Lazy Loading Images
const components = {
types: {
image: ({value}) => (
<Image
src={urlFor(value).url()}
alt={value.alt}
width={800}
height={400}
loading="lazy" // Enable lazy loading
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..." // Add blur placeholder
/>
),
},
}
2. Optimize Sanity Queries
Use GROQ projections to fetch only what you need:
const query = `*[_type == "post"] {
title,
slug,
content[] {
...,
_type == "image" => {
...,
asset->
}
}
}`
3. Implement Content Caching
lib/sanity.js
// lib/sanity.js
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
useCdn: true, // Enable CDN for better performance
apiVersion: '2023-05-03',
})
// Cache frequently accessed content
export async function getCachedPost(slug) {
const cacheKey = `post-${slug}`
// Check cache first (Redis, memory, etc.)
const cached = await getFromCache(cacheKey)
if (cached) return cached
// Fetch from Sanity if not cached
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug }
)
// Cache for future requests
await setCache(cacheKey, post, 3600) // Cache for 1 hour
return post
}
Real-World Implementation: Customer Success Story
Challenge: A development team was spending 40% of their time maintaining different content rendering logic across their web app, mobile app, and email templates.
Solution: They implemented Portable Text with custom components, creating a single source of truth for all content.
Results:
- 60% reduction in content-related bugs
- 3x faster feature development when content changes were needed
- 100% consistency across all platforms
- Content team became completely autonomous in managing rich content
This exemplifies our core value of Customer Success – by choosing the right tools and implementing them thoughtfully, you can transform your entire content workflow.
Best Practices and Common Pitfalls
Do This
- Start with minimal configuration and expand based on actual needs
- Create reusable component libraries for consistent rendering
- Implement proper error boundaries to handle malformed content gracefully
- Use TypeScript for better development experience
Avoid These Mistakes
- Don’t over-engineer your initial schema – complexity should grow with needs
- Don’t ignore accessibility – always include proper alt text and semantic HTML
- Don’t forget mobile rendering – test your components on all screen sizes
- Don’t skip error handling – malformed Portable Text can break your entire page
TypeScript Implementation
interface PortableTextBlock {
_type: 'block'
children: Array<{
_type: 'span'
text: string
marks?: string[]
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'blockquote'
}
interface CustomComponents {
block: {
[key: string]: React.ComponentType<{children: React.ReactNode}>
}
marks: {
[key: string]: React.ComponentType<{children: React.ReactNode, value?: any}>
}
}
Taking It Further: Advanced Techniques
Custom Annotations for Enhanced Interactivity
Create interactive elements within your text:
schemas/tooltip.js
// schemas/tooltip.js
export default {
name: 'tooltip',
title: 'Tooltip',
type: 'object',
fields: [
{
name: 'text',
title: 'Tooltip Text',
type: 'string'
}
]
}
// Render with interactive tooltip
const components = {
marks: {
tooltip: ({children, value}) => (
<span
className="border-b border-dashed border-blue-500 cursor-help"
title={value.text}
>
{children}
</span>
),
},
}
Integration with Headless Commerce
Embed product information directly in content:
schemas/productEmbed.js
// schemas/productEmbed.js
export default {
name: 'productEmbed',
title: 'Product Embed',
type: 'object',
fields: [
{
name: 'product',
title: 'Product',
type: 'reference',
to: [{type: 'product'}]
},
{
name: 'layout',
title: 'Layout',
type: 'string',
options: {
list: ['card', 'inline', 'featured']
}
}
]
}
Your Next Steps
Ready to transform your content management workflow? Here’s your action plan:
- Start Small: Implement a basic Portable Text field in one content type
- Measure Impact: Track how much time your team saves on content-related tasks
- Expand Gradually: Add custom blocks and components based on actual needs
- Share Success: Document your wins and share them with your team
Resources for Continued Learning
- Official Documentation: portabletext.org
- Sanity Documentation: sanity.io/docs/portable-text
- Community Examples: github.com/sanity-io/portable-text
Need Help Getting Started?
We believe in collaboration and supporting your success. If you’re implementing Portable Text in your project and need guidance, don’t hesitate to reach out. Our commitment to excellence means we’re here to help you build something amazing.