Portable Text Custom Annotations: Schema-First Design for Consistent Rendering
Portable Text is structured data, not HTML. Learn how to define custom annotations in the Sanity schema and build a shared serializer library so every frontend renders internal links, embeds, and custom marks consistently — without silent data loss.
06 Aug 2025, 19:12 UTC

The problem with treating Portable Text as HTML
Portable Text is not HTML. It's a JSON specification that represents rich text as an array of block objects — headings, paragraphs, lists — each containing typed spans with marks (bold, italic) and optional inline objects called annotations. When teams first adopt Sanity, a common mistake is to store HTML strings in a Portable Text field or to interpolate raw JSON into dangerouslySetInnerHTML. Both approaches break the queryable, portable nature of the content and cause render errors when the serializer encounters unknown annotation types.
The key engineering decision is to treat Portable Text as structured data: define every custom annotation in the schema, then map each annotation to a serializer component. This schema-first approach guarantees that the Studio editor validates the shape of the data and that every frontend (web, mobile, email) renders it consistently.
Defining a custom annotation in the schema
Suppose you need an internal link that references another Sanity document (e.g., a product page). You declare the annotation as an object type and add it to the block's of array. In schemas/portableText.js:
export default {
name: 'portableText',
type: 'array',
title: 'Body',
of: [
{ type: 'block' },
{
name: 'internalLink',
type: 'object',
title: 'Internal link',
fields: [
{ name: 'reference', type: 'reference', to: [{ type: 'product' }, { type: 'page' }] },
{ name: 'label', type: 'string', title: 'Link text (optional)' }
]
}
]
}
Run this in your Sanity Studio repo (requires sanity CLI and write access to the schema folder). After deploying the schema, the block editor shows an "Internal link" toolbar button that lets editors pick a document and optionally override the link text. The stored Portable Text block will contain a span with a marks array that includes the _key of the internalLink annotation object.
Building a shared serializer library
Create a framework-agnostic serializer package (e.g., @myorg/portable-text-serializers) so Next.js, React Native, and email templates all use the same mapping. Install the core packages:
npm i @portabletext/react @portabletext/to-html
In components/portableText/serializers.js (run in your frontend repo, requires React 18+):
import { PortableText } from '@portabletext/react'
const components = {
marks: {
internalLink: ({ value, children }) => (
{value.label || children}
)
},
types: {
// custom block types if any
}
}
export function renderPortableText(blocks) {
return
}
export function renderPortableTextToHtml(blocks) {
// for email or static export
const { default: toHtml } = require('@portabletext/to-html')
return toHtml(blocks, { components })
}
Now any page can import renderPortableText and pass the body field from a GROQ query:
// app/products/[slug]/page.tsx
import { renderPortableText } from '@/components/portableText/serializers'
export default async function ProductPage({ params }) {
const product = await sanityClient.fetch(`*[_type=='product' && slug.current==$slug][0]{..., 'body': body}`)
return {renderPortableText(product.body)}
}
Trade-offs and limitations
- Maintenance burden: Every new annotation requires a schema change, a Studio toolbar configuration (if you want a custom button), and a serializer component in each target framework. For small teams, limit custom annotations to high-value cases (internal links, product embeds, CTA buttons).
- Performance on large documents: Portable Text arrays exceeding ~10,000 blocks degrade GROQ query latency. If you have massive content (e.g., book chapters), split into separate documents and reference them with a
blockContenttype that uses_type == 'block'references. - Real-time collaboration edge cases: Concurrent edits to the same block can produce unexpected span splits. Test editorial workflows with multiple users before relying on granular history diffs for audit trails.
- Asset privacy: Image URLs inside Portable Text are public by default. If content requires authentication, proxy asset requests through your backend or use Sanity's asset pipeline token.
Actionable next steps
- Audit your current Portable Text schemas: list every custom annotation and verify a serializer exists for each frontend target.
- Add a missing serializer for any annotation that currently renders as plain text or throws a console warning.
- Extract the serializer mapping into a shared npm package (or internal monorepo library) to enforce a single source of truth.
- Run a GROQ query in the Vision plugin (
*[_type=='post'][0]{body}) and inspect the raw JSON to confirm annotation structure matches your schema. - Set up a CI step that renders a sample Portable Text document with each serializer (React, HTML, React Native) and snapshot-tests the output.
By treating Portable Text as structured data — not HTML — you gain queryable content, consistent cross-platform rendering, and editorial workflows that survive schema evolution.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.