
Portable Text Serialization
- 1.5k installs
- 171 repo stars
- Updated July 29, 2026
- sanity-io/agent-toolkit
portable-text-serialization is an agent skill for 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,.
About
The portable-text-serialization skill is designed for 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,. 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. Invoke when the user 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.
- Decorators: string values in marks[] like "strong", "em", "underline", "code".
- Annotations: keys in marks[] referencing entries in markDefs[] (e.g., links, internal references).
- 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.
Portable Text Serialization by the numbers
- 1,507 all-time installs (skills.sh)
- +91 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #244 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
portable-text-serialization capabilities & compatibility
- Capabilities
- decorators: string values in marks[] like "stron · annotations: keys in marks[] referencing entries · react / next.js: rules/react.md — @portabletext/ · svelte / sveltekit: rules/svelte.md — @portablet
- Use cases
- frontend
What portable-text-serialization says it does
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 c
Render and serialize Portable Text to React, Svelte, Vue, Astro, HTML, Markdown, and plain text. Use when implementing Portable Text rendering in any frontend f
npx skills add https://github.com/sanity-io/agent-toolkit --skill portable-text-serializationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 171 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | sanity-io/agent-toolkit ↗ |
How do I 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,?
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,.
Who is it for?
Developers using portable text serialization workflows documented in SKILL.md.
Skip if: Skip when the task falls outside portable-text-serialization scope or needs a different stack.
When should I use this skill?
User 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, e
What you get
Completed portable-text-serialization workflow with documented commands, files, and expected deliverables.
- framework serializer components
- HTML or Markdown conversion output
- plain-text extraction utility
By the numbers
- Targets seven output formats: React, Svelte, Vue, Astro, HTML, Markdown, and plain text
- Skill metadata lists version 1.0.0 with MIT license from Sanity
Files
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 blocksMarks come in two forms:
- Decorators: string values in
marks[]like"strong","em","underline","code" - Annotations: keys in
marks[]referencing entries inmarkDefs[](e.g., links, internal references)
Component Mapping Pattern (All Frameworks)
Every @portabletext/* library accepts a components object with these keys:
| Key | Renders | Props/Data |
|---|---|---|
types | Custom block/inline types (image, code, CTA) | value (the block data) |
marks | Decorators + annotations | children + value (mark data) |
block | Block styles (h1, normal, blockquote) | children |
list | List wrappers (ul, ol) | children |
listItem | List items | children |
hardBreak | Line breaks within a block | — |
Framework-Specific Rules
Read the rule file matching your framework:
- React / Next.js:
rules/react.md—@portabletext/reactornext-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:
| Target | Package |
|---|---|
| React Native | @portabletext/react-native-portabletext |
| React PDF | @portabletext/react-pdf-portabletext |
| Solid | solid-portabletext |
| Qwik | portabletext-qwik |
| Shopify Liquid | portable-text-to-liquid |
| PHP | sanity-php (SanityBlockContent class) |
| Python | portabletext-html |
| C# / .NET | dotnet-portable-text |
| Dart / Flutter | flutter_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
}
}
}Serialize Portable Text to Astro
Use astro-portabletext to render PT in Astro projects. This is the officially recommended library for Sanity + Astro.
npm install astro-portabletextBasic Usage
---
import {PortableText} from 'astro-portabletext'
const {value} = Astro.props
---
<PortableText value={value} />Custom Components
Pass custom components to override default rendering:
---
import {PortableText} from 'astro-portabletext'
import ImageBlock from './ImageBlock.astro'
import CodeBlock from './CodeBlock.astro'
import Link from './Link.astro'
const {value} = Astro.props
const components = {
type: {
image: ImageBlock,
code: CodeBlock,
},
mark: {
link: Link,
},
block: {
h1: 'h1',
h2: 'h2',
blockquote: 'blockquote',
},
}
---
<PortableText {value} {components} />Custom Type Component
---
// ImageBlock.astro
const {node} = Astro.props
---
<figure>
<img src={urlFor(node).width(800).url()} alt={node.alt || ''} />
{node.caption && <figcaption>{node.caption}</figcaption>}
</figure>Custom Mark Component
---
// Link.astro
const {node} = Astro.props
const href = node?.href || ''
const rel = href.startsWith('/') ? undefined : 'noreferrer noopener'
---
<a {href} {rel}><slot /></a>Using Slots for Customization
astro-portabletext supports Astro's slot system for simpler customization:
---
import {PortableText} from 'astro-portabletext'
---
<PortableText value={value}>
<fragment slot="block:h1">
<h1 class="text-4xl font-bold"><slot /></h1>
</fragment>
<fragment slot="mark:strong">
<strong class="font-black"><slot /></strong>
</fragment>
</PortableText>usePortableText Helper
For more control, use the usePortableText render function:
---
import {usePortableText} from 'astro-portabletext'
const {value} = Astro.props
const {render} = usePortableText(value)
---
<div class="prose">
{render()}
</div>Reference
Serialize Portable Text to HTML
Use @portabletext/to-html for server-side HTML string generation — useful for RSS feeds, emails, static rendering, or any non-framework context.
npm install @portabletext/to-htmlBasic Usage
import {toHTML} from '@portabletext/to-html'
const html = toHTML(portableTextBlocks, {components})⚠️ Security: Escape HTML
Unlike framework renderers, toHTML returns raw strings. You must sanitize output.
Use htm + vhtml for safe templating, or the built-in escapeHTML utility:
import {toHTML, escapeHTML, uriLooksSafe} from '@portabletext/to-html'
import htm from 'htm'
import vhtml from 'vhtml'
const h = htm.bind(vhtml)Custom Components
Components are functions returning HTML strings:
const components = {
types: {
image: ({value}) => {
return `<figure>
<img src="${escapeHTML(value.url)}" alt="${escapeHTML(value.alt || '')}" />
${value.caption ? `<figcaption>${escapeHTML(value.caption)}</figcaption>` : ''}
</figure>`
},
code: ({value}) => {
return `<pre data-language="${escapeHTML(value.language)}"><code>${escapeHTML(value.code)}</code></pre>`
},
},
marks: {
link: ({children, value}) => {
const href = value?.href || ''
if (!uriLooksSafe(href)) return children
const rel = href.startsWith('/') ? '' : ' rel="noreferrer noopener"'
return `<a href="${escapeHTML(href)}"${rel}>${children}</a>`
},
strong: ({children}) => `<strong>${children}</strong>`,
em: ({children}) => `<em>${children}</em>`,
highlight: ({children}) => `<mark>${children}</mark>`,
},
block: {
h1: ({children}) => `<h1>${children}</h1>`,
h2: ({children}) => `<h2>${children}</h2>`,
blockquote: ({children}) => `<blockquote>${children}</blockquote>`,
normal: ({children}) => `<p>${children}</p>`,
},
list: {
bullet: ({children}) => `<ul>${children}</ul>`,
number: ({children}) => `<ol>${children}</ol>`,
},
listItem: {
bullet: ({children}) => `<li>${children}</li>`,
},
}With htm/vhtml (Auto-Escaped)
Using htm + vhtml auto-escapes attribute values, preventing XSS from user content that could break out of attributes in raw template literals:
const components = {
types: {
image: ({value}) => h`<img src=${value.url} alt=${value.alt || ''} />`,
},
marks: {
link: ({children, value}) => {
if (!uriLooksSafe(value?.href || '')) return children
return h`<a href=${value.href}>${children}</a>`
},
},
}Use Cases
| Use Case | Why toHTML |
|---|---|
| RSS/Atom feeds | Need raw HTML string |
| Email templates | No framework runtime |
| Static site generation | Pre-render at build time |
| API responses | Return HTML from endpoints |
| PDF generation | Feed HTML to PDF libraries |
Reference
Serialize Portable Text to Markdown
Use @portabletext/markdown to convert PT blocks to Markdown strings. Useful for AI/LLM pipelines, static site generators, README generation, and anywhere Markdown is the target format.
npm install @portabletext/markdownBasic Usage
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(portableTextBlocks)Built-in Support
Out of the box, portableTextToMarkdown handles:
- Headings (h1–h6)
- Paragraphs
- Bold (
**), italic (_), inline code (``), strikethrough (~~`) - Links (
[text](url)) - Blockquotes (
>) - Ordered and unordered lists (including nested)
- Code blocks (fenced with language)
- Horizontal rules (
---) - Images (
) - Tables (GFM)
Built-in Type Renderers
The library exports default renderers for common block object types. Enable them explicitly:
import {
portableTextToMarkdown,
DefaultCodeBlockRenderer,
DefaultImageRenderer,
DefaultHorizontalRuleRenderer,
DefaultTableRenderer,
DefaultHtmlRenderer,
} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(blocks, {
types: {
'code': DefaultCodeBlockRenderer, // {code, language?} → fenced code block
'image': DefaultImageRenderer, // {src, alt?, title?} → 
'horizontal-rule': DefaultHorizontalRuleRenderer, // → ---
'table': DefaultTableRenderer, // {rows, headerRows?} → GFM table
'html': DefaultHtmlRenderer, // {html} → raw HTML
},
})Custom Renderers
Handle custom block types and marks with renderer functions:
const markdown = portableTextToMarkdown(blocks, {
// Custom block types — receives {value, index, isInline}
types: {
callout: ({value}) => `> **${value.title}**\n> ${value.text}`,
image: ({value, isInline}) => {
if (isInline) return ''
return ``
},
},
// Custom block style renderers — receives {value, children, index}
block: {
h1: ({children}) => `# ${children}`,
blockquote: ({children}) => `> ${children}`,
},
// Custom mark renderers — receives {value, children, text, markType, markKey}
marks: {
highlight: ({children}) => `==${children}==`,
internalLink: ({children, value}) => `[${children}](/docs/${value.slug})`,
},
// Custom list item renderer — receives {value, children, listIndex}
listItem: ({children}) => children,
// Control spacing between blocks — function, not string
blockSpacing: ({current, next}) => {
if (current.listItem && next.listItem) return '\n'
return undefined // use default (\n\n)
},
// Handle unknown types gracefully
unknownType: ({value}) => `<!-- Unknown type: ${value._type} -->`,
unknownMark: ({children}) => children,
})Use Cases
| Use Case | Why Markdown |
|---|---|
| AI/LLM context | Models work well with Markdown input |
| Static site generators | Hugo, Jekyll, Eleventy consume Markdown |
| README generation | Generate docs from Sanity content |
| Email (with converter) | Markdown → HTML for email templates |
| Export/backup | Human-readable content export |
| Documentation pipelines | Sanity as docs CMS, output as Markdown |
Bidirectional: Also Converts Markdown → PT
The same package also provides markdownToPortableText() for the reverse direction. See the portable-text-conversion skill for details.
Reference
- @portabletext/markdown
- Part of the portabletext/editor monorepo
Extract Plain Text from Portable Text
Every @portabletext/* library exports a toPlainText() utility. Use it for meta descriptions, search indexing, summaries, and anywhere you need raw text without markup.
Usage
// From any framework library:
import {toPlainText} from '@portabletext/react'
// or: import {toPlainText} from '@portabletext/svelte'
// or: import {toPlainText} from '@portabletext/vue'
// or: import {toPlainText} from '@portabletext/to-html'
const plainText = toPlainText(portableTextBlocks)Common Patterns
Meta Description
function getMetaDescription(body: PortableTextBlock[]): string {
const text = toPlainText(body)
return text.length > 160 ? text.slice(0, 157) + '...' : text
}Search Indexing
// Index document content for search
const searchableText = toPlainText(document.body)Slug Generation
import slugify from 'slugify'
const slug = slugify(toPlainText(blocks), {lower: true, strict: true})Character/Word Count
const text = toPlainText(blocks)
const wordCount = text.split(/\s+/).filter(Boolean).length
const charCount = text.lengthBehavior
- Extracts text from all
spanchildren inblocktype nodes - Joins blocks with double newlines (
\n\n) - Ignores custom block types (images, code blocks, etc.)
- Strips all marks (bold, links, etc.) — returns raw text only
Reference
Serialize Portable Text to React
Use @portabletext/react (or re-exported from next-sanity) to render PT in React/Next.js.
npm install @portabletext/reactBasic Usage
import {PortableText} from '@portabletext/react'
// or: import {PortableText} from 'next-sanity'
export function Body({value}: {value: PortableTextBlock[]}) {
return <PortableText value={value} components={components} />
}Typed Components Object
import type {PortableTextComponents} from '@portabletext/react'
const components: PortableTextComponents = {
// Block styles
block: {
h1: ({children}) => <h1 className="text-4xl font-bold">{children}</h1>,
h2: ({children}) => <h2 className="text-3xl font-semibold">{children}</h2>,
blockquote: ({children}) => (
<blockquote className="border-l-4 pl-4 italic">{children}</blockquote>
),
// 'normal' is the default paragraph style
},
// Custom block types
types: {
image: ({value}) => (
<img
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
loading="lazy"
/>
),
code: ({value}) => (
<pre data-language={value.language}>
<code>{value.code}</code>
</pre>
),
},
// Marks (decorators + annotations)
marks: {
// Decorator
highlight: ({children}) => (
<span className="bg-yellow-200">{children}</span>
),
// Annotation
link: ({children, value}) => {
const rel = !value?.href?.startsWith('/') ? 'noreferrer noopener' : undefined
return (
<a href={value?.href} rel={rel}>
{children}
</a>
)
},
internalLink: ({children, value}) => (
<a href={`/${value?.slug}`}>{children}</a>
),
},
// Lists
list: {
bullet: ({children}) => <ul className="list-disc ml-6">{children}</ul>,
number: ({children}) => <ol className="list-decimal ml-6">{children}</ol>,
},
listItem: {
bullet: ({children}) => <li>{children}</li>,
},
}Props Reference
| Component type | Props received |
|---|---|
block.* | {children, value} — value is the full block |
types.* | {value, isInline} — value is the custom block data |
marks.* | {children, value, markType, markKey} — value is the markDef data |
list.* | {children, value} |
listItem.* | {children, value} |
Performance: Stabilize the Components Object
❌ Bad — recreated every render:
function Body({value}) {
return <PortableText value={value} components={{
types: {image: ({value}) => <img src={value.url} />}
}} />
}✅ Good — defined outside or memoized:
const components: PortableTextComponents = {
types: {image: ({value}) => <img src={value.url} />}
}
function Body({value}) {
return <PortableText value={value} components={components} />
}Plain Text Extraction
import {toPlainText} from '@portabletext/react'
const text = toPlainText(blocks) // for meta descriptions, search indexingTailwind Typography Shortcut
For simple blogs without custom blocks, wrap in prose:
<article className="prose lg:prose-xl">
<PortableText value={value} />
</article>Reference
Serialize Portable Text to Svelte
Use @portabletext/svelte (requires Svelte 5+) to render PT in Svelte/SvelteKit.
npm install @portabletext/svelteBasic Usage
<script>
import {PortableText} from '@portabletext/svelte'
let {value} = $props()
</script>
<PortableText {value} components={components} />Custom Components
Svelte components receive a portableText prop with value, global, and indexInParent. Child content is passed via Svelte snippets.
Block Styles
<!-- Heading.svelte -->
<script>
let {portableText, children} = $props()
const {value} = portableText
</script>
{#if value.style === 'h1'}
<h1 class="text-4xl font-bold">{@render children()}</h1>
{:else if value.style === 'h2'}
<h2 class="text-3xl font-semibold">{@render children()}</h2>
{:else}
<p>{@render children()}</p>
{/if}Custom Types
<!-- ImageBlock.svelte -->
<script>
let {portableText} = $props()
const {value} = portableText
</script>
<figure>
<img src={urlFor(value).width(800).url()} alt={value.alt || ''} />
{#if value.caption}
<figcaption>{value.caption}</figcaption>
{/if}
</figure>Mark Components (Annotations)
<!-- Link.svelte -->
<script>
let {portableText, children} = $props()
const {value} = portableText
const href = value?.href || ''
</script>
<a {href} rel={href.startsWith('/') ? undefined : 'noreferrer noopener'}>
{@render children()}
</a>Assembling Components
<script>
import {PortableText} from '@portabletext/svelte'
import ImageBlock from './ImageBlock.svelte'
import CodeBlock from './CodeBlock.svelte'
import Link from './Link.svelte'
let {value} = $props()
const components = {
types: {
image: ImageBlock,
code: CodeBlock,
},
marks: {
link: Link,
},
block: {
h1: ({children}) => `<h1>${children}</h1>`, // or use a component
},
}
</script>
<PortableText {value} {components} />Passing Context
Pass external data to all components via context:
<PortableText
{value}
{components}
context={{dataset: 'production', footnotes}}
/>Access in components via portableText.global.context.
Plain Text Extraction
import {toPlainText} from '@portabletext/svelte'
const text = toPlainText(blocks)Reference
Serialize Portable Text to Vue
Use @portabletext/vue to render PT in Vue 3 / Nuxt applications.
npm install @portabletext/vueBasic Usage
<script setup lang="ts">
import {PortableText} from '@portabletext/vue'
import type {PortableTextBlock} from '@portabletext/types'
const props = defineProps<{value: PortableTextBlock[]}>()
</script>
<template>
<PortableText :value="value" :components="components" />
</template>Custom Components
Vue components can be defined as render functions, SFCs, or JSX.
Render Function Style (Concise)
import {h} from 'vue'
import type {PortableTextVueComponents} from '@portabletext/vue'
const components: PortableTextVueComponents = {
types: {
image: ({value}) => h('img', {src: urlFor(value).width(800).url(), alt: value.alt || ''}),
code: ({value}) => h('pre', {'data-language': value.language}, h('code', value.code)),
},
marks: {
link: ({value}, {slots}) => {
const rel = !value?.href?.startsWith('/') ? 'noreferrer noopener' : undefined
return h('a', {href: value?.href, rel}, slots.default?.())
},
highlight: (_, {slots}) => h('span', {class: 'bg-yellow-200'}, slots.default?.()),
},
block: {
h1: (_, {slots}) => h('h1', {class: 'text-4xl font-bold'}, slots.default?.()),
h2: (_, {slots}) => h('h2', {class: 'text-3xl font-semibold'}, slots.default?.()),
blockquote: (_, {slots}) => h('blockquote', {class: 'border-l-4 pl-4 italic'}, slots.default?.()),
},
list: {
bullet: (_, {slots}) => h('ul', {class: 'list-disc ml-6'}, slots.default?.()),
number: (_, {slots}) => h('ol', {class: 'list-decimal ml-6'}, slots.default?.()),
},
}SFC Style (For Complex Components)
<!-- ImageBlock.vue -->
<script setup lang="ts">
import type {PortableTextComponentProps} from '@portabletext/vue'
const props = defineProps<PortableTextComponentProps<{
asset: {_ref: string}
alt?: string
caption?: string
}>>()
</script>
<template>
<figure>
<img :src="urlFor(value).width(800).url()" :alt="value.alt || ''" />
<figcaption v-if="value.caption">{{ value.caption }}</figcaption>
</figure>
</template>Then register:
import ImageBlock from './ImageBlock.vue'
const components = {
types: {
image: ImageBlock,
},
}Props Pattern
Custom components receive:
| Prop | Description |
|---|---|
value | The block/mark data |
index | Position in parent array |
isInline | Whether this is an inline element |
renderNode | Internal renderer (rarely needed) |
Children are passed via slots (slots.default?.()), not props.
Plain Text Extraction
import {toPlainText} from '@portabletext/vue'
const text = toPlainText(blocks)Reference
Related skills
How it compares
Pick portable-text-serialization for Sanity Portable Text JSON; generic Markdown renderers cannot handle custom block types and mark decorators.
FAQ
What does portable-text-serialization do?
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,.
When should I use portable-text-serialization?
User 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
Is portable-text-serialization safe to install?
Review the Security Audits panel on this page before installing in production.