A L E X   K U Z N E T S O F
Sep 16,2025
Read Time: 6 min

Mastering Sanity CMS Portable Text: A Developer’s Complete Guide

Transform your content management workflow with structured, flexible text that scales with your applications

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:

HTML
<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:

JSON
[
  {
    "_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:

JavaScript
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:

Bash
npm install @portabletext/react

Create a basic renderer:

JSX
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:

JSX
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:

JavaScript
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:

JavaScript
// In your schema
{
  name: 'content',
  type: 'array',
  of: [
    {type: 'block'},
    {type: 'codeBlock'}, // Add your custom block
    {type: 'image'}
  ]
}

Render it in React:

JSX
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

JSX
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:

JavaScript
const query = `*[_type == "post"] {
  title,
  slug,
  content[] {
    ...,
    _type == "image" => {
      ...,
      asset->
    }
  }
}`

3. Implement Content Caching

JavaScript
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

  1. Start with minimal configuration and expand based on actual needs
  2. Create reusable component libraries for consistent rendering
  3. Implement proper error boundaries to handle malformed content gracefully
  4. Use TypeScript for better development experience

Avoid These Mistakes

  1. Don’t over-engineer your initial schema – complexity should grow with needs
  2. Don’t ignore accessibility – always include proper alt text and semantic HTML
  3. Don’t forget mobile rendering – test your components on all screen sizes
  4. Don’t skip error handling – malformed Portable Text can break your entire page

TypeScript Implementation

TypeScript
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:

JavaScript
schemas/tooltip.js
// schemas/tooltip.js
export default {
  name: 'tooltip',
  title: 'Tooltip',
  type: 'object',
  fields: [
    {
      name: 'text',
      title: 'Tooltip Text',
      type: 'string'
    }
  ]
}
JSX
// 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:

JavaScript
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:

  1. Start Small: Implement a basic Portable Text field in one content type
  2. Measure Impact: Track how much time your team saves on content-related tasks
  3. Expand Gradually: Add custom blocks and components based on actual needs
  4. Share Success: Document your wins and share them with your team

Resources for Continued Learning

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.

Get Started Today

This tutorial reflects our commitment to innovation in content management and our dedication to customer success. By choosing structured content approaches like Portable Text, you’re investing in a solution that will scale with your applications and empower your content team.

What will you build with Portable Text? Share your implementations and let’s learn together as a community.

Shared by