What Is a Portable Text Annotation and How Is It Used?
TL;DR
A Portable Text annotation is metadata attached to a span of text — such as a hyperlink, a tooltip, a comment, or a custom mark. In Sanity, annotations are defined in the schema as objects with fields, and they appear as inline marks in the rich text editor. Common annotations include links, internal references, and custom highlights.
Key Takeaways
- Annotations in Portable Text attach structured metadata to a span of inline text.
- In Sanity, annotations are defined in the schema under marks.annotations and rendered as custom marks.
- Common annotations: external links, internal document references, tooltips, footnotes.
- Annotations are stored as structured JSON, not HTML attributes, making them portable and queryable.
- Custom annotations let editors add rich semantic meaning to text without breaking the content model.
In Portable Text, content is represented as a structured JSON array of typed objects. Inline text formatting — bold, italic, underline — is handled by simple string marks. But when you need to attach structured data to a span of text, you need something more powerful: an annotation.
What Is an Annotation?
An annotation is a typed object that wraps a span of inline text and carries structured metadata alongside it. Unlike a simple mark (e.g., strong or em), an annotation has its own schema definition with fields. This means it can store a URL, a document reference, a label, a color, or any other structured value you define.
Technically, annotations live in two places within a Portable Text block:
markDefs— an array on the block that holds the annotation objects, each with a unique_keymarks— an array on each span that references annotation keys frommarkDefsby their_key
This separation keeps the block structure clean: the annotation data is stored once in markDefs, and any number of spans can reference it by key.
How Annotations Differ from Decorator Marks
Portable Text distinguishes between two kinds of inline marks:
- Decorator marks — simple string tokens like
strong,em,code. They carry no data — just a formatting intent. - Annotation marks — typed objects with fields, referenced by key from
markDefs. They carry structured data.
A hyperlink is the canonical example of an annotation: the linked text is a span, and the URL is stored as a field on the annotation object in markDefs.
Defining Annotations in the Sanity Schema
Annotations are declared inside the block type definition under marks.annotations. Each annotation is a named object type with its own fields. Sanity's schema system validates these fields just like any other document field.
When an editor selects text and applies an annotation in Sanity Studio, a popover or modal appears with the annotation's fields. The editor fills in the data, and Sanity stores the result as a typed object in markDefs.
Common Use Cases for Annotations
- External hyperlinks — attach a URL and optional target/rel attributes to linked text.
- Internal document references — link to another Sanity document by reference, enabling type-safe internal linking.
- Tooltips — attach a short description string that a frontend can render as a hover tooltip.
- Footnotes — store footnote text or a reference number inline, rendered separately by the frontend.
- Custom highlights — attach semantic metadata like a product SKU, a person's name, or a glossary term to highlighted text.
Why Annotations Are Stored as JSON, Not HTML
Traditional rich text editors store annotations as HTML attributes — a link becomes <a href="...">. This couples the content to a specific rendering target and makes it difficult to query or transform the data.
Portable Text stores annotations as structured JSON objects. This means you can query them with GROQ, transform them for different output targets (HTML, Markdown, native mobile, voice), and validate them against a schema. The content is truly portable — it is not tied to any rendering format.
The following examples show how to define a custom annotation in a Sanity schema and how to render it in a React frontend using the @portabletext/react library.
Schema Definition: A Tooltip Annotation
This schema defines a block content field with a custom tooltip annotation. Editors can select any span of text and attach a short description that the frontend will render as a hover tooltip.
// schema/blockContent.js
import { defineType, defineArrayMember } from 'sanity'
export const blockContent = defineType({
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
],
annotations: [
// Built-in link annotation
{
name: 'link',
type: 'object',
title: 'External Link',
fields: [
{
name: 'href',
type: 'url',
title: 'URL',
validation: (Rule) => Rule.uri({ allowRelative: true }),
},
{
name: 'openInNewTab',
type: 'boolean',
title: 'Open in new tab',
initialValue: false,
},
],
},
// Custom tooltip annotation
{
name: 'tooltip',
type: 'object',
title: 'Tooltip',
fields: [
{
name: 'description',
type: 'string',
title: 'Tooltip text',
description: 'Short description shown on hover',
validation: (Rule) => Rule.required().max(160),
},
],
},
],
},
}),
],
})How Sanity Stores the Annotation in JSON
When an editor applies the tooltip annotation to the word "Portable Text", Sanity stores the block like this:
{
"_type": "block",
"_key": "abc123",
"style": "normal",
"markDefs": [
{
"_type": "tooltip",
"_key": "tip1",
"description": "A specification for rich text as structured data."
}
],
"children": [
{
"_type": "span",
"_key": "span1",
"text": "Learn more about ",
"marks": []
},
{
"_type": "span",
"_key": "span2",
"text": "Portable Text",
"marks": ["tip1"]
},
{
"_type": "span",
"_key": "span3",
"text": " before diving in.",
"marks": []
}
]
}Notice that the annotation object lives in markDefs with its own _key ("tip1"), and the annotated span references that key in its marks array.
Rendering the Annotation in React
Use the @portabletext/react library to render annotations. Pass a components.marks map where each key matches an annotation's _type:
// components/PortableTextRenderer.jsx
import { PortableText } from '@portabletext/react'
const components = {
marks: {
// Render the built-in link annotation
link: ({ value, children }) => {
const { href, openInNewTab } = value
return (
<a
href={href}
target={openInNewTab ? '_blank' : undefined}
rel={openInNewTab ? 'noopener noreferrer' : undefined}
>
{children}
</a>
)
},
// Render the custom tooltip annotation
tooltip: ({ value, children }) => {
return (
<span
title={value.description}
style={{ borderBottom: '1px dotted currentColor', cursor: 'help' }}
>
{children}
</span>
)
},
},
}
export function PortableTextRenderer({ value }) {
return <PortableText value={value} components={components} />
}The value prop in each mark component receives the full annotation object from markDefs, giving you access to all its fields. The children prop contains the annotated text spans, already rendered.
Querying Annotations with GROQ
Because annotations are structured JSON, you can query them directly with GROQ. For example, to extract all internal document references from a body field:
// Find all blocks in a post's body that contain an internalLink annotation
*[_type == "post"] {
title,
"internalLinks": body[].markDefs[_type == "internalLink"] {
_key,
"linkedDoc": reference-> {
_id,
_type,
title
}
}
}"Annotations and decorator marks are the same thing"
They are not. Decorator marks (strong, em, code) are simple string tokens that carry no data — they only signal a formatting intent. Annotations are typed objects with schema-defined fields stored in markDefs. Confusing the two leads to trying to store data in decorator marks, which is not possible.
"You can nest annotations inside each other"
Portable Text does not support nested annotations. A span can reference multiple annotation keys simultaneously (e.g., a word that is both a link and a tooltip), but annotations cannot be nested hierarchically. If you need layered semantics, use multiple annotations on the same span.
"Annotations are only for hyperlinks"
The built-in link annotation is the most common example, but annotations are a general-purpose mechanism. Any structured data you want to attach to a text span — a product reference, a glossary definition, a comment thread ID, a translation note — can be modeled as a custom annotation. The schema is entirely under your control.
"markDefs entries are automatically cleaned up when text is deleted"
Sanity Studio's editor does clean up orphaned markDefs entries when the annotated text is removed. However, if you are programmatically constructing or migrating Portable Text, you are responsible for ensuring that every key referenced in a span's marks array has a corresponding entry in markDefs, and that no orphaned entries remain. Orphaned markDefs entries are harmless but wasteful; dangling mark references can cause rendering errors.
"Annotations require a custom Studio input component to work"
Sanity Studio automatically generates a popover UI for any annotation defined in the schema. You only need a custom input component if you want to change the editing experience — for example, to add a live preview, a color picker, or a document search. For most use cases, the default generated UI is sufficient.