PluginBench
Skill
Official
Pass
Audit score 90

portable-text-serialization

sanity-io/agent-toolkit

Render and serialize Portable Text across React, Vue, Svelte, Astro, HTML, Markdown, and plain text.

What is portable-text-serialization?

Portable Text is a JSON-based rich-text format. This skill provides framework-specific libraries and component-mapping patterns to render PT blocks, marks, and custom types in any frontend framework or convert to HTML/Markdown server-side.

  • Render Portable Text in React, Vue, Svelte, Astro, and other frameworks using component-mapping patterns
  • Convert Portable Text to HTML strings, Markdown, or plain text
  • Handle custom block and inline types (image, code, callouts) with explicit component definitions
  • Manage decorators (strong, em, underline) and annotations (links, references) via marks
  • Support nested lists, block styles (h1, blockquote, normal), and hard breaks
  • Troubleshoot rendering issues with missing components and mark definitions

How to install portable-text-serialization

npx skills add https://github.com/sanity-io/agent-toolkit --skill portable-text-serialization
Prerequisites
  • Install the appropriate `@portabletext/*` library for your framework (e.g., `@portabletext/react`, `@portabletext/vue`)
  • Understand Portable Text structure: blocks, spans, marks, markDefs, and custom types
  • Have a Sanity project or Portable Text JSON data available
Claude Code
Cursor
Windsurf
Cline

How to use portable-text-serialization

  1. 1.Choose the rule file matching your framework (React, Vue, Svelte, Astro, HTML, Markdown, or plain text)
  2. 2.Define a `components` object mapping `types`, `marks`, `block`, `list`, and `listItem` to your framework's renderers
  3. 3.For custom block types (image, code, etc.), create explicit component handlers that receive the block `value`
  4. 4.Use GROQ queries to expand references and assets inside custom blocks before passing to the serializer
  5. 5.Pass the `components` object and Portable Text array to the framework library (e.g., `<PortableText value={blocks} components={components} />`)
  6. 6.Test with `onMissingComponent` to catch unmapped types during development

Use cases

Good for
  • Rendering rich-text blog posts or articles from a Sanity CMS in a Next.js or Vue app
  • Converting Portable Text to HTML on the server for email or static exports
  • Building a custom code-block renderer with syntax highlighting for a documentation site
  • Extracting plain text from Portable Text for search indexing or previews
  • Implementing internal-link references that resolve to slugs or URLs in a custom mark component
Who it's for
  • Frontend developers using React, Vue, Svelte, or Astro with Sanity CMS
  • Full-stack developers converting Portable Text to HTML or Markdown server-side
  • Content teams building custom block types (image galleries, CTAs, embeds)
  • Developers integrating Sanity content into static-site generators or headless setups

portable-text-serialization FAQ

What's the difference between decorators and annotations in marks?

Decorators are simple string values like 'strong' or 'em' applied directly to spans. Annotations are keys in the `marks[]` array that reference entries in `markDefs[]`, used for complex marks like links or internal references that carry additional data (href, reference ID, etc.).

Do I need to handle custom types like 'image' or 'code'?

Yes. PT renderers only handle standard blocks by default. Any custom type requires an explicit component mapping in your `components` object, or it will not render.

Can I use the same components object across renders?

Yes, and you should. Define `components` outside your render function or memoize it to avoid unnecessary re-renders in React/Vue. Recreating it on every render is inefficient.

How do I handle missing or unknown component types?

All libraries accept an `onMissingComponent` callback. Set it to a custom function to log warnings, or pass `false` to suppress them silently.

What GROQ patterns should I use for Portable Text with custom blocks?

Always expand references and assets inside custom blocks: use `asset->` for images and `@.reference->slug.current` for internal links. This ensures all data is available when your component renderer receives the block value.

Full instructions (SKILL.md)

Source of truth, from sanity-io/agent-toolkit.


name: portable-text-serialization description: Render and serialize Portable Text to React, Svelte, Vue, Astro, HTML, Markdown, and plain text. Use when implementing Portable Text rendering in any frontend framework, building custom serializers for non-standard block types, converting Portable Text to HTML strings server-side, converting Portable Text to Markdown, extracting plain text from Portable Text, or troubleshooting rendering issues with marks, blocks, lists, or custom types. license: MIT metadata: author: sanity version: "1.0.0"

Portable Text Serialization

Render Portable Text content across frameworks using the @portabletext/* library family. Each library follows the same component-mapping pattern: you provide a components object that maps PT node types to framework-specific renderers.

Portable Text Structure (Quick Reference)

PT is an array of blocks. Each block has _type, optional style, children (spans), markDefs, listItem, and level.

Root array
├── block (_type: "block")
│   ├── style: "normal" | "h1" | "h2" | "blockquote" | ...
│   ├── children: [span, span, ...]
│   │   └── span: { _type: "span", text: "...", marks: ["strong", "<markDefKey>"] }
│   ├── markDefs: [{ _key, _type: "link", href: "..." }, ...]
│   ├── listItem: "bullet" | "number" (optional)
│   └── level: 1, 2, 3... (optional, for nested lists)
├── custom block (_type: "image" | "code" | any custom type)
└── ...more blocks

Marks come in two forms:

  • Decorators: string values in marks[] like "strong", "em", "underline", "code"
  • Annotations: keys in marks[] referencing entries in markDefs[] (e.g., links, internal references)

Component Mapping Pattern (All Frameworks)

Every @portabletext/* library accepts a components object with these keys:

KeyRendersProps/Data
typesCustom block/inline types (image, code, CTA)value (the block data)
marksDecorators + annotationschildren + value (mark data)
blockBlock styles (h1, normal, blockquote)children
listList wrappers (ul, ol)children
listItemList itemschildren
hardBreakLine breaks within a block—

Framework-Specific Rules

Read the rule file matching your framework:

  • React / Next.js: rules/react.md — @portabletext/react or next-sanity
  • Svelte / SvelteKit: rules/svelte.md — @portabletext/svelte
  • Vue / Nuxt: rules/vue.md — @portabletext/vue
  • Astro: rules/astro.md — astro-portabletext
  • HTML (server-side): rules/html.md — @portabletext/to-html
  • Markdown: rules/markdown.md — @portabletext/markdown
  • Plain text extraction: rules/plain-text.md — @portabletext/toolkit

Additional Community Serializers

These are listed on portabletext.org but don't have dedicated rule files:

TargetPackage
React Native@portabletext/react-native-portabletext
React PDF@portabletext/react-pdf-portabletext
Solidsolid-portabletext
Qwikportabletext-qwik
Shopify Liquidportable-text-to-liquid
PHPsanity-php (SanityBlockContent class)
Pythonportabletext-html
C# / .NETdotnet-portable-text
Dart / Flutterflutter_sanity_portable_text

Common Patterns (All Frameworks)

Custom Types Need Explicit Components

PT renderers only handle standard blocks by default. Custom types (image, code, callToAction, etc.) require explicit component mappings — they won't render otherwise.

Keep Components Object Stable

In React/Vue, define components outside the render function or memoize it. Recreating on every render causes unnecessary re-renders.

Handle Missing Components Gracefully

All libraries accept onMissingComponent to control behavior when encountering unknown types:

  • false — suppress warnings
  • Custom function — log or report

Querying PT with GROQ

Always expand references inside custom blocks:

body[]{
  ...,
  _type == "image" => {
    ...,
    asset->
  },
  markDefs[]{
    ...,
    _type == "internalLink" => {
      ...,
      "slug": @.reference->slug.current
    }
  }
}