
What Is Portable Text in Sanity?
Portable Text is Sanity's open specification for storing rich text as structured JSON instead of HTML. A Portable Text field is an array of blocks: each paragraph, heading or list item is a block object containing children (spans of text) with marks for formatting such as bold or links, and you can mix in custom objects like images, code snippets or call-to-action boxes alongside the text. Because the content is data rather than markup, you can render the same field as HTML on a website, as native views in a mobile app, as plain text for search or as anything else, and you decide exactly how each piece looks.
If you've used a traditional CMS, you're used to a rich text editor that saves an HTML string. That's convenient until you need the content somewhere HTML doesn't fit, or want to change how a heading renders across thousands of posts. Portable Text was designed to avoid those problems. This guide explains how the format is structured, how it compares with HTML and Markdown, how you define it in a Sanity schema, how to query it with GROQ and how to render it in your front end.
Why Sanity Uses Portable Text
Storing rich text as HTML ties your content to one presentation. Inline styles, classes, editor quirks and embedded markup all end up in the database, and you have to parse or sanitise them later. Portable Text separates what the content means from how it looks:
- Presentation-agnostic: the same content can be rendered to React, Vue, Svelte, Astro, plain HTML, email templates, native apps or voice assistants.
- Queryable: because it's JSON, GROQ can filter, project and extract text from rich text, such as finding every post that embeds a particular product.
- Extensible: you can add your own block types and inline annotations, with their own fields, validation and editor UI.
- Safe by default: there's no arbitrary HTML to sanitise. Your renderer only outputs what you map each type to.
- Collaborative: every block and span has a
_key, so Sanity can track changes precisely and support real-time editing.
Portable Text is an open specification, published at portabletext.org, with serialisers maintained for many languages and frameworks. It's not limited to Sanity, though Sanity is where most people meet it. For the bigger picture on how Sanity stores data, see Sanity Content Lake explained.
The Structure of Portable Text
Here's a short paragraph with a bold word and a link, followed by a heading:
[
{
"_type": "block",
"_key": "b1",
"style": "normal",
"markDefs": [
{ "_type": "link", "_key": "m1", "href": "https://www.sanity.io" }
],
"children": [
{ "_type": "span", "_key": "s1", "text": "Sanity is a ", "marks": [] },
{ "_type": "span", "_key": "s2", "text": "headless", "marks": ["strong"] },
{ "_type": "span", "_key": "s3", "text": " CMS. Read the ", "marks": [] },
{ "_type": "span", "_key": "s4", "text": "docs", "marks": ["m1"] },
{ "_type": "span", "_key": "s5", "text": ".", "marks": [] }
]
},
{
"_type": "block",
"_key": "b2",
"style": "h2",
"markDefs": [],
"children": [
{ "_type": "span", "_key": "s6", "text": "Getting started", "marks": [] }
]
}
]
The key pieces are:
| Property | Where | Purpose |
|---|---|---|
_type: "block" | Block | Marks a text block, as opposed to a custom object |
style | Block | The block's role, such as normal, h2 or blockquote |
children | Block | The spans (and any inline objects) that make up the text |
markDefs | Block | Data for annotations used in this block, such as link URLs |
listItem and level | Block | Turn a block into a list item, and set its nesting depth |
_type: "span" | Child | A run of text with the same formatting |
marks | Span | Decorator names like strong, or keys pointing into markDefs |
_key | Everywhere | A unique ID within the array, used for editing and rendering |
Decorators and Annotations
Marks come in two kinds:
- Decorators: simple formatting with no extra data, such as
strong,em,code,underlineandstrike-through. The mark is just the name. - Annotations: formatting that carries data, such as a link with an
href. The span's mark is a key, and the data lives in the block'smarkDefsarray.
This split keeps spans small and lets you attach rich data, like a reference to another document, to any range of text. The guide to inline annotations and links in Portable Text goes deeper.
Lists Are Flat
There's no list container in Portable Text. A list is simply consecutive blocks with a listItem value, and level controls nesting:
[
{ "_type": "block", "_key": "l1", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [], "children": [{ "_type": "span", "_key": "x1", "text": "First", "marks": [] }] },
{ "_type": "block", "_key": "l2", "style": "normal", "listItem": "bullet", "level": 2, "markDefs": [], "children": [{ "_type": "span", "_key": "x2", "text": "Nested", "marks": [] }] }
]
Renderers group these into ul and ol elements for you. Keeping lists flat makes them much easier to edit and transform than nested HTML.
Custom Objects
Anything that isn't a text block is just another object in the array with its own _type:
{
"_type": "image",
"_key": "img1",
"asset": { "_type": "reference", "_ref": "image-abc123-1200x800-jpg" },
"alt": "A diagram of the Content Lake"
}
Images, videos, code blocks, tables and product cards all work this way. Objects can also appear inline inside a block's children, for things like an inline icon or a footnote marker.
Portable Text vs HTML vs Markdown
| Portable Text | HTML | Markdown | |
|---|---|---|---|
| Storage format | JSON | String of markup | String of plain text with syntax |
| Custom content types | Any object with fields | Custom elements or embedded markup | Limited, needs extensions like MDX |
| Queryable in the database | Yes, with GROQ | Only as a string | Only as a string |
| Rendering | You map each type to output | Rendered as-is or sanitised | Parsed to HTML |
| Multi-channel | Designed for it | Tied to the web | Mostly web |
| Editing experience | Structured editor in Studio | WYSIWYG editors | Text editor |
Markdown is great for developers writing files in Git. HTML is the right output for browsers. Portable Text sits in between as a storage format: structured enough to query and transform, flexible enough to hold any content type, and convertible to HTML or Markdown when you need them. If you're migrating existing content, how to convert HTML to Portable Text shows the process.
Defining a Portable Text Field
In a schema, a Portable Text field is an array whose members include the block type:
// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity';
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'Heading 2', value: 'h2' },
{ title: 'Heading 3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' },
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
],
},
}),
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [defineField({ name: 'alt', type: 'string', title: 'Alt text' })],
}),
],
}),
],
});
Studio renders this as its Portable Text Editor, with a toolbar for the styles, lists and decorators you've allowed, and an insert menu for the image type. If you leave out styles, lists or marks, Sanity uses sensible defaults, including a link annotation.
Restricting the options is a feature, not a limitation. Editors can't pick a heading level your design doesn't support, and you never have to handle formatting you didn't plan for. For a reusable approach, many projects define the array once as a blockContent type and use it in several documents, as described in building reusable object types in Sanity.
To add your own content types to the editor, see adding custom blocks to Sanity Portable Text.
Querying Portable Text With GROQ
Because Portable Text is plain JSON in the Content Lake, you query it like any other field:
*[_type == "post" && slug.current == $slug][0]{
title,
body
}
Several patterns come up often.
Extract plain text for excerpts, meta descriptions or search, with pt::text():
*[_type == "post"]{
title,
"plainText": pt::text(body)
}
Trim the result to the length you need in your front end, for example to 160 characters for a meta description.
Expand references and assets inside the array with a projection, so your renderer has what it needs:
*[_type == "post" && slug.current == $slug][0]{
title,
body[]{
...,
_type == "image" => {
...,
"url": asset->url,
"dimensions": asset->metadata.dimensions
},
markDefs[]{
...,
_type == "internalLink" => { "slug": @.reference->slug.current }
}
}
}
Filter by content, for example posts that contain a code block:
*[_type == "post" && count(body[_type == "code"]) > 0]{ title }
These use array filters and projections covered in how to query nested arrays in GROQ.
Rendering Portable Text
To show Portable Text on a page, you use a serialiser: a library that walks the array and calls the component or function you've mapped to each type. Official and community serialisers include:
- @portabletext/react: React components, including React Server Components.
- @portabletext/vue and @portabletext/svelte: for those frameworks.
- @portabletext/to-html: plain HTML strings, useful for RSS feeds and email.
- astro-portabletext: a community package for Astro.
A minimal React example:
import { PortableText, type PortableTextComponents } from '@portabletext/react';
const components: PortableTextComponents = {
block: {
h2: ({ children }) => <h2 className="mt-10 text-2xl font-bold">{children}</h2>,
},
marks: {
link: ({ value, children }) => <a href={value?.href}>{children}</a>,
},
};
export function Body({ value }: { value: any[] }) {
return <PortableText value={value} components={components} />;
}
Default components handle standard blocks, lists and decorators, so you only override what you want to change and add components for your custom types. The full walkthrough is in how to render Portable Text in React.
When Portable Text Fits Best
Portable Text suits almost any editorial content in Sanity, but it shines when:
- Content goes to more than one place: a website, an app, newsletters and a search index.
- Editors embed structured content: product cards, quotes, videos or code alongside prose.
- Design changes often: you can restyle every heading by changing one component.
- You need to query inside rich text: find posts that link to a page, or that use a deprecated block.
For short, unformatted fields such as titles, labels and SEO descriptions, a plain string or text field is simpler and better.
FAQ: Portable Text
No. Portable Text is an open specification with serialisers for many frameworks, and other tools can read and write it. Sanity created it and uses it for rich text fields, which is where most people encounter it.
Use a serialiser. @portabletext/to-html produces an HTML string, and framework libraries such as @portabletext/react render it as components. You map each block style, mark and custom type to the output you want.
In GROQ, use pt::text(body) to return the text content as a string. In JavaScript, @portabletext/react exports a toPlainText() helper that does the same on the client or server.
A decorator is simple formatting, such as bold or italic, stored as a mark name on the span. An annotation carries data, such as a link URL, stored in the block's markDefs array and referenced by key from the span's marks.
Yes. Add the types to the array's of list in your schema. They're stored as objects in the same array as text blocks, and you render them with your own components.
The _key uniquely identifies each item in the array. Sanity uses it for real-time collaboration and precise patches, and renderers use it as a stable key when outputting lists of components.
Conclusion
Portable Text is Sanity's way of treating rich text as structured data. Text lives in blocks and spans, formatting is expressed as decorators and annotations, and anything richer, from images to product cards, is just another typed object in the array. That makes your content queryable with GROQ, safe to render, and ready for any channel you need.
Start by defining a block array with only the styles and marks your design supports, then add custom types as your editors need them. With a serialiser like @portabletext/react mapping each type to a component, you get full control over presentation without ever storing a line of HTML.


