Type something to search...
How to Add Inline Annotations and Links in Portable Text

How to Add Inline Annotations and Links in Portable Text

To add inline annotations and links in Portable Text, define them under marks.annotations on the block member of your Portable Text field. Each annotation is an object type with its own fields, such as a link with an href or an internalLink with a reference to another document. When an editor selects text and applies the annotation, Sanity stores the data once in the block's markDefs array and adds that entry's _key to the marks of the selected spans. On the front end, you expand any references in GROQ and render each annotation with a component under marks in your Portable Text renderer.

Annotations are how Portable Text attaches data to a range of text. Links are the obvious example, but the same mechanism handles internal links that survive slug changes, footnotes, glossary terms, tooltips and anything else where text needs extra information. This guide explains how annotations are stored, how to replace the default link with a validated version, how to add internal links, how to build your own annotations, how to query them, and how to render them in React.

How Annotations Are Stored

Portable Text has two kinds of marks. Decorators like strong and em are stored as plain names on a span. Annotations carry data, so they're stored in two places:

{
  "_type": "block",
  "_key": "b1",
  "style": "normal",
  "markDefs": [
    {
      "_type": "link",
      "_key": "a7f3",
      "href": "https://www.sanity.io/docs",
      "openInNewTab": true
    }
  ],
  "children": [
    { "_type": "span", "_key": "s1", "text": "Read the ", "marks": [] },
    { "_type": "span", "_key": "s2", "text": "official docs", "marks": ["a7f3"] },
    { "_type": "span", "_key": "s3", "text": " for details.", "marks": [] }
  ]
}

The span's marks contains "a7f3", which points at the markDefs entry with that _key. A span can carry several marks at once, so a bold link has ["strong", "a7f3"]. Renderers look up each key and pass the data to your component. For a broader overview of the format, see what is Portable Text in Sanity.

The Default Link Annotation

If you define a block member without marks.annotations, Studio gives you a basic link annotation with an href field. That's enough to get started, but it has gaps: there's no control over which URL schemes are allowed, no "open in new tab" option and no way to link to other documents without pasting their URL.

As soon as you define marks.annotations yourself, you replace the default, so the next step is to define your own link.

Step 1: Define a Validated External Link

// schemaTypes/blockContent.ts
import { defineArrayMember, defineField, defineType } from 'sanity';
import { LinkIcon } from '@sanity/icons';

export const blockContent = defineType({
  name: 'blockContent',
  title: 'Block Content',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      marks: {
        annotations: [
          defineArrayMember({
            name: 'link',
            title: 'External link',
            type: 'object',
            icon: LinkIcon,
            fields: [
              defineField({
                name: 'href',
                title: 'URL',
                type: 'url',
                validation: (rule) =>
                  rule.required().uri({
                    scheme: ['http', 'https', 'mailto', 'tel'],
                  }),
              }),
              defineField({
                name: 'openInNewTab',
                title: 'Open in new tab',
                type: 'boolean',
                initialValue: false,
              }),
            ],
          }),
        ],
      },
    }),
  ],
});

The url field type gives editors a URL input, and the uri() rule restricts it to the schemes you list, which keeps javascript: links out of your content. If you want editors to enter relative paths such as /contact, add allowRelative: true to the uri() options, though an internal link annotation is usually a better choice for that.

Keeping the name link matches the conventional name, so existing content made with the default annotation keeps working and most renderers recognise it.

The validation rules guide covers uri() and other rules in more detail.

Step 2: Add an Internal Link Annotation

Linking to another page by pasting its URL breaks as soon as the slug changes. An internal link stores a reference instead:

defineArrayMember({
  name: 'internalLink',
  title: 'Internal link',
  type: 'object',
  icon: DocumentIcon,
  fields: [
    defineField({
      name: 'reference',
      title: 'Linked document',
      type: 'reference',
      to: [{ type: 'post' }, { type: 'page' }],
      validation: (rule) => rule.required(),
    }),
  ],
}),

Import DocumentIcon from @sanity/icons alongside LinkIcon, and add this entry to the same annotations array. Editors now see two link buttons in the toolbar. The internal link opens a search for posts and pages, and the stored markDefs entry holds a reference:

{
  "_type": "internalLink",
  "_key": "c912",
  "reference": { "_type": "reference", "_ref": "post-what-is-groq" }
}

References also give you referential integrity: Sanity warns editors before they delete a document that something else links to. How to use references in Sanity schemas explains options like weak references and filtering the search.

Step 3: Create a Custom Annotation

Any object type can be an annotation. A glossary term or tooltip is a common example:

import { HelpCircleIcon } from '@sanity/icons';

defineArrayMember({
  name: 'definition',
  title: 'Definition',
  type: 'object',
  icon: HelpCircleIcon,
  fields: [
    defineField({
      name: 'text',
      title: 'Definition text',
      type: 'text',
      rows: 2,
      validation: (rule) => rule.required().max(200),
    }),
  ],
}),

When an editor applies it, a small dialog asks for the definition text. Footnotes, citations, affiliate links, inline translations and "track this click" markers all follow the same pattern.

Customising How Annotations Look in Studio

By default, annotated text is underlined in the editor. You can give an annotation its own appearance with components.annotation:

// schemaTypes/blockContent.tsx
import type { BlockAnnotationProps } from 'sanity';

const DefinitionAnnotation = (props: BlockAnnotationProps) => (
  <span style={{ borderBottom: '1px dotted currentColor' }}>
    {props.renderDefault(props)}
  </span>
);

// on the definition annotation
components: { annotation: DefinitionAnnotation },

Calling props.renderDefault(props) keeps Studio's built-in behaviour, such as the popover for editing, and wraps it in your styling. Rename the file to .tsx once it contains JSX.

Decorator or Annotation?

NeedUseWhy
Bold, italic, inline code, highlightDecoratorNo data beyond the formatting
External or internal linkAnnotationNeeds a URL or reference
Footnote, citation, tooltipAnnotationNeeds extra text or a reference
Inline price or iconInline objectReplaces text rather than marking it

For decorators and inline objects, see adding custom blocks to Sanity Portable Text.

Step 4: Query Annotations With GROQ

External links work without any query changes, because the href is stored in markDefs. Internal links hold only a reference ID, so you need to follow it to get the slug:

*[_type == "post" && slug.current == $slug][0]{
  title,
  body[]{
    ...,
    markDefs[]{
      ...,
      _type == "internalLink" => {
        "slug": @.reference->slug.current,
        "docType": @.reference->_type
      }
    }
  }
}

The body[] traversal steps into each block, markDefs[] steps into each annotation, and the conditional projection adds slug and docType only to internal links. Blocks that aren't text, such as images, have no markDefs, so they pass through untouched. This pattern relies on dereferencing in GROQ and querying nested arrays.

A useful bonus: because annotations are data, you can query them across your dataset. This finds every post that links to a given document:

*[_type == "post" && $id in body[].markDefs[].reference._ref]{
  title,
  "slug": slug.current
}

Step 5: Render Annotations in React

With @portabletext/react, annotations render through marks, keyed by the annotation's name. The component receives children (the annotated text) and value (the markDefs entry, plus anything your query added):

// src/components/portable-text-components.tsx
import Link from 'next/link';
import type { PortableTextComponents } from '@portabletext/react';

const pathFor = (docType?: string, slug?: string) => {
  if (!slug) return null;
  return docType === 'page' ? `/${slug}` : `/blog/${slug}`;
};

export const components: PortableTextComponents = {
  marks: {
    link: ({ value, children }) => {
      const href: string | undefined = value?.href;
      if (!href) return <>{children}</>;
      const newTab = Boolean(value?.openInNewTab);
      return (
        <a
          href={href}
          target={newTab ? '_blank' : undefined}
          rel={newTab ? 'noopener noreferrer' : undefined}
        >
          {children}
        </a>
      );
    },
    internalLink: ({ value, children }) => {
      const path = pathFor(value?.docType, value?.slug);
      return path ? <Link href={path}>{children}</Link> : <>{children}</>;
    },
    definition: ({ value, children }) => (
      <abbr title={value?.text} className="cursor-help underline decoration-dotted">
        {children}
      </abbr>
    ),
  },
};

A few deliberate choices in this code:

  • Fall back to plain text: if a link has no href, or an internal link points to a deleted or unpublished document, render the text without a link rather than a broken anchor.
  • Use next/link for internal links: you get client-side navigation and prefetching in the App Router.
  • Add rel for new tabs: noopener noreferrer stops the opened page from accessing window.opener.
  • Map types to paths in one place: pathFor keeps your URL structure out of the content, so changing routes doesn't mean editing posts.

The full renderer setup is in how to render Portable Text in React.

Tips for Working With Annotations

  • Keep link as the external link name: it matches defaults and community renderers.
  • Validate everything: required fields and uri() rules stop empty or unsafe links reaching production.
  • Don't nest documents inside annotations: keep them small. If an annotation needs many fields, reference a separate document.
  • Plan for missing references: drafts and deletions happen. Your renderer should always have a fallback.
  • Watch your perspective: with the published perspective, a reference to an unpublished document dereferences to null, which your fallback should handle.

FAQ: Annotations and Links in Portable Text

An annotation is a mark that carries data, such as a link with a URL. The data is stored once in the block's markDefs array, and each annotated span lists that entry's _key in its marks.

Defining marks.annotations replaces the default link annotation. Add your own link annotation to the array, ideally named link, so editors still have external links.

Create an annotation with a reference field pointing at the document types you want, such as post and page. In GROQ, follow the reference inside markDefs to fetch the slug, then build the URL in your renderer.

Use a url field with a uri() validation rule that lists allowed schemes, such as http, https, mailto and tel. Studio then rejects other schemes before the content can be published.

Add a boolean field such as openInNewTab to your link annotation. In the renderer, set target="_blank" and rel="noopener noreferrer" when it's true.

Yes. Because annotations are stored as data, a filter such as $id in body[].markDefs[].reference._ref returns every post whose Portable Text body contains an internal link to that document.


Conclusion

Annotations let you attach structured data to any range of text in Portable Text. Define them under marks.annotations, starting with a validated external link and an internalLink that stores a reference, then add custom annotations like definitions or footnotes as your content needs them. Sanity keeps the data in markDefs and links it to spans by key.

On the front end, expand internal references in your GROQ query, render each annotation with a component under marks, and always fall back to plain text when data is missing. The result is links that survive slug changes, content you can audit with a query, and markup you fully control.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper