Type something to search...
Adding Custom Blocks to Sanity Portable Text

Adding Custom Blocks to Sanity Portable Text

To add custom blocks to Sanity Portable Text, define an object type with the fields you need, such as a callout with tone and text, and add it to the of array of your Portable Text field alongside { type: 'block' }. Studio then shows it in the editor's insert menu, editors fill in its fields in a dialog, and the block is stored as a typed object in the same array as your paragraphs. On the front end, render it by adding a component with the same name to the types key of your Portable Text renderer. The same approach works for inline objects inside a paragraph, and you can also add custom styles and decorators to text blocks themselves.

Plain paragraphs and headings only go so far. Real articles need callouts, quotes with attribution, product cards, embedded videos and code. Custom blocks let editors add those as structured content with validation, instead of hacking them in with formatting. This guide covers defining block types, making them readable in the editor with previews, adding inline objects, extending text blocks with custom styles and decorators, embedding references to other documents, and rendering it all in React.

How Custom Blocks Fit Into Portable Text

A Portable Text field is an array. Text blocks are one member type, and anything else you add to of becomes another option:

defineField({
  name: 'body',
  type: 'array',
  of: [
    defineArrayMember({ type: 'block' }),   // paragraphs, headings, lists
    defineArrayMember({ type: 'image' }),   // a built-in type
    defineArrayMember({ type: 'callout' }), // your custom type
  ],
}),

Stored content keeps each item's _type, so a callout sits between paragraphs like this:

[
  { "_type": "block", "_key": "p1", "style": "normal", "markDefs": [], "children": [{ "_type": "span", "_key": "s1", "text": "Before you start:", "marks": [] }] },
  { "_type": "callout", "_key": "c1", "tone": "warning", "text": "Back up your dataset first." }
]

If the format is new to you, what is Portable Text in Sanity explains blocks, spans and marks.

Step 1: Define a Custom Block Type

Start with a callout box. Define it as its own object type so you can reuse it in any Portable Text field:

// schemaTypes/objects/callout.ts
import { defineField, defineType } from 'sanity';
import { InfoOutlineIcon } from '@sanity/icons';

export const callout = defineType({
  name: 'callout',
  title: 'Callout',
  type: 'object',
  icon: InfoOutlineIcon,
  fields: [
    defineField({
      name: 'tone',
      title: 'Tone',
      type: 'string',
      options: {
        list: [
          { title: 'Info', value: 'info' },
          { title: 'Tip', value: 'tip' },
          { title: 'Warning', value: 'warning' },
        ],
        layout: 'radio',
      },
      initialValue: 'info',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'text',
      title: 'Text',
      type: 'text',
      rows: 3,
      validation: (rule) => rule.required().max(400),
    }),
  ],
  preview: {
    select: { title: 'text', tone: 'tone' },
    prepare({ title, tone }) {
      return {
        title: title ?? 'Empty callout',
        subtitle: tone ? `Callout (${tone})` : 'Callout',
      };
    },
  },
});

Then register it in your schema:

// schemaTypes/index.ts
import { callout } from './objects/callout';
import { post } from './post';

export const schemaTypes = [post, callout];

@sanity/icons is installed with Studio, so the icon import works out of the box. The icon appears in the insert menu and in the block's preview, which helps editors recognise it at a glance. For more on structuring objects for reuse, see building reusable object types in Sanity.

Step 2: Add It to Your Portable Text Field

Many projects keep the Portable Text definition in its own blockContent type, so every document that needs rich text uses the same set of blocks:

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

export const blockContent = defineType({
  name: 'blockContent',
  title: 'Block Content',
  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' },
      ],
    }),
    defineArrayMember({
      type: 'image',
      options: { hotspot: true },
      fields: [defineField({ name: 'alt', type: 'string', title: 'Alt text' })],
    }),
    defineArrayMember({ type: 'callout' }),
  ],
});
// in schemaTypes/post.ts
defineField({ name: 'body', type: 'blockContent' }),

Restart or reload Studio and open a post. The insert menu in the editor toolbar now lists Callout. Choosing it opens a dialog with the tone and text fields, and the block shows inline as a preview card using the preview configuration.

Previews Make or Break the Editing Experience

Without a preview, a custom block shows only its type name, so a document with ten callouts looks like ten identical boxes. Always select at least one meaningful field as the title. For image-heavy blocks, select an image as media:

preview: {
  select: { title: 'heading', media: 'image' },
},

Step 3: Add a Block That References Another Document

Custom blocks don't have to hold all their content. A "related post" block can point to another document, so the card stays up to date when that post's title changes:

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

export const relatedPost = defineType({
  name: 'relatedPost',
  title: 'Related post',
  type: 'object',
  icon: LinkIcon,
  fields: [
    defineField({
      name: 'post',
      type: 'reference',
      to: [{ type: 'post' }],
      validation: (rule) => rule.required(),
    }),
    defineField({ name: 'label', type: 'string', initialValue: 'Read next' }),
  ],
  preview: {
    select: { title: 'post.title', subtitle: 'label' },
  },
});

Note post.title in the preview: Studio follows the reference to show the linked post's title. In your front-end query, expand the reference with a conditional projection:

*[_type == "post" && slug.current == $slug][0]{
  title,
  body[]{
    ...,
    _type == "relatedPost" => {
      ...,
      "post": post->{ title, "slug": slug.current }
    }
  }
}

How to use references in Sanity schemas covers reference options such as filtering which documents can be chosen.

Step 4: Add Inline Objects

Some content belongs inside a sentence rather than between paragraphs, such as a footnote marker, a product price or a stock ticker. Add those to the block type's own of array:

defineArrayMember({
  type: 'block',
  of: [
    defineArrayMember({
      name: 'productPrice',
      title: 'Product price',
      type: 'object',
      fields: [
        defineField({
          name: 'product',
          type: 'reference',
          to: [{ type: 'product' }],
        }),
      ],
      preview: { select: { title: 'product.title' } },
    }),
  ],
}),

Inline objects appear in the toolbar's insert menu with an inline marker. They're stored in the block's children array, next to the spans. Use them sparingly: anything longer than a word or two is usually clearer as a separate block or as an annotation on existing text.

Step 5: Add Custom Styles and Decorators

Custom blocks aren't the only way to extend Portable Text. You can also add options to text blocks themselves.

Custom Block Styles

A style changes the role of a whole paragraph. For a larger introductory paragraph, add a lead style. The optional component controls how it looks inside Studio:

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

const LeadStyle = (props: BlockStyleProps) => (
  <span style={{ fontSize: '1.25em' }}>{props.children}</span>
);

// inside the block member
styles: [
  { title: 'Normal', value: 'normal' },
  { title: 'Lead', value: 'lead', component: LeadStyle },
  { title: 'Heading 2', value: 'h2' },
],

Since the file now contains JSX, give it a .tsx extension.

Custom Decorators

A decorator formats a range of text without extra data, like bold. Here's a highlight decorator with its own icon and editor rendering:

import type { BlockDecoratorProps } from 'sanity';
import { HighlightIcon } from '@sanity/icons';

const Highlight = (props: BlockDecoratorProps) => (
  <span style={{ backgroundColor: '#fff3a3' }}>{props.children}</span>
);

// inside the block member
marks: {
  decorators: [
    { title: 'Strong', value: 'strong' },
    { title: 'Emphasis', value: 'em' },
    { title: 'Code', value: 'code' },
    { title: 'Highlight', value: 'highlight', icon: HighlightIcon, component: Highlight },
  ],
},

When you set marks.decorators, you replace the defaults, so list every decorator you want to keep. If the formatting needs data, such as a link URL or a tooltip, use an annotation instead, as shown in how to add inline annotations and links in Portable Text.

Choosing the Right Extension

You want to addUseStored as
A box, embed or card between paragraphsCustom block type in the field's ofObject in the main array
Something small inside a sentenceInline object in the block's ofObject in a block's children
A different kind of paragraphCustom stylestyle value on the block
Formatting with no dataCustom decoratorMark name on spans
Formatting with dataAnnotationEntry in markDefs, key on spans

Step 6: Render Custom Blocks in React

Each custom type needs a component in types, keyed by its _type. Custom styles go in block and custom decorators in marks:

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

const toneClasses: Record<string, string> = {
  info: 'border-blue-500 bg-blue-50',
  tip: 'border-green-500 bg-green-50',
  warning: 'border-amber-500 bg-amber-50',
};

export const components: PortableTextComponents = {
  types: {
    callout: ({ value }) => (
      <aside
        role="note"
        className={`my-6 border-l-4 p-4 ${toneClasses[value.tone] ?? toneClasses.info}`}
      >
        {value.text}
      </aside>
    ),
    relatedPost: ({ value }) =>
      value.post?.slug ? (
        <p className="my-6 font-semibold">
          {value.label ?? 'Read next'}:{' '}
          <Link href={`/blog/${value.post.slug}`}>{value.post.title}</Link>
        </p>
      ) : null,
    productPrice: ({ value }) => (
      <span className="font-mono">{value.product?.price ?? ''}</span>
    ),
  },
  block: {
    lead: ({ children }) => <p className="text-xl leading-8">{children}</p>,
  },
  marks: {
    highlight: ({ children }) => <mark>{children}</mark>,
  },
};

Remember to expand the productPrice reference in your query too, as with relatedPost. For the full renderer setup, including fallbacks for unknown types, see how to render Portable Text in React.

Practical Tips

  • Name types clearly: the name becomes _type in stored data and the key in your renderer. Renaming later means migrating content.
  • Validate inside blocks: rules on a block's fields run like any other field, so a callout without text shows an error before publishing. See how to add validation rules to Sanity fields.
  • Keep blocks focused: a block with fifteen fields is a sign it should be a separate document referenced from the body.
  • Render defensively: editors can add a block and leave it half filled in, especially in drafts. Return null or a placeholder when required data is missing.
  • Limit the menu: every option in the insert menu is a choice editors have to make. Only add blocks your design actually supports.

FAQ: Custom Blocks in Portable Text

Define an object type with the fields you need, register it in your schema, and add it to the of array of your Portable Text field next to the block type. Studio adds it to the editor's insert menu automatically.

The type has no preview configuration. Add a preview with select pointing at a meaningful field, such as title or text, and optionally a media field, so editors can tell blocks apart.

A custom block sits between paragraphs in the main array. An inline object is added to the block type's own of array and lives inside a paragraph's children, next to the text spans.

Yes. Add a reference field to the object. In Studio previews you can select fields through the reference, and in GROQ you expand it with the dereference operator inside a conditional projection on body.

Add it to marks.decorators on the block member with a title, value and optionally an icon and component. Setting decorators replaces the defaults, so include strong, em and any others you want to keep.

Yes. Add a component for the new _type under components.types in your renderer. Until you do, the renderer uses its unknown type fallback and logs a warning.


Conclusion

Custom blocks turn Portable Text from a rich text field into a flexible content builder. Define each block as an object type with validation and a preview, add it to the field's of array, and render it with a matching component in types. Inline objects, custom styles and decorators give you finer control inside the text itself.

Pick the smallest extension that fits each need, keep block names stable, and render defensively for half-finished drafts. Do that, and editors get a structured, predictable editing experience while you keep full control of the output.

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