Embedding and editing portable text blocks within Sanity content editor

4 replies
Last updated: Jun 27, 2024
Hey all, new to Sanity and I'm trying to extend the content editor so that blocks of portable text can be embedded and edited right within the portable text editor.
Sorta like this:

export default defineType({
  title: "Blog Body",
  name: "blogBody",
  type: "array",
  of: [
    {
      title: "Content",
      type: "block",
      styles: [
        { title: "Normal", value: "normal" },
        { title: "Quote", value: "blockquote" },
        { title: "Heading", value: "h2" },
        { title: "Sub Heading 1", value: "h3" },
        { title: "Sub Heading 2", value: "h4" },
        { title: "Sub Heading 3", value: "h5" },
      ],
      marks: {
        decorators: [
          { title: "Strong", value: "strong" },
          { title: "Emphasis", value: "em" },
          { title: "Inline Code", value: "code" },
          { title: "Underline", value: "underline" },
          { title: "Strike Through", value: "strike-through" },
          {
            title: "Highlight",
            value: "highlight",
            component: Highlight,
            icon: BulbOutlineIcon,
          },
        ],
      },
    },
    { title: "Image", type: "defaultImage" },
    { type: "code", options: { withFilename: true } },
    { title: "Youtube", type: "youtube" },
    {
      title: "Section",
      type: "object",
      name: "section",
      icon: BlockContentIcon,
      fields: [
        {
          title: "Content",
          type: "array",
          name: "content",
          of: [{ type: "block" }],
        },
      ],
    },
  ],
});
The objective here though is that I want
Section
objects to display the actual content inline within the parent editor.
AI Update

I understand what you're trying to do – you want nested portable text blocks (like your Section object) to render as editable content inline within the parent portable text editor, rather than showing a preview or requiring a modal/dialog to edit.

Unfortunately, this is not recommended and generally not supported in Sanity Studio. As mentioned in this community thread, putting a portable text editor inside of a portable text editor drastically increases your attribute count and creates significant technical challenges.

Why This Architecture Doesn't Exist

Nested portable text editors create several problems:

  • Attribute count explosion: Each nested block adds to your document's complexity, potentially hitting limits
  • Focus management: Managing cursor position and selection across nested editors is extremely complex
  • Data structure integrity: Keeping _key values and block structure consistent becomes fragile
  • User confusion: It's unclear which editor level you're working in
  • Serialization complexity: Rendering nested portable text on the frontend becomes more difficult

The Sanity team has intentionally not built this feature because the UX and technical tradeoffs generally aren't worth it.

What You Can Do Instead

1. Use marks or annotations (though as you noted, these don't work across blocks)

2. Use a separator/divider approach (the recommended solution from the community):

Create a custom block object that acts as a section divider, then process the blocks on the frontend to wrap content between dividers into sections. This is exactly what the original poster settled on in that thread:

{
  title: "Section Rule",
  type: "object",
  name: "sectionRule",
  icon: BlockElementIcon,
  fields: [
    {
      title: "Visible",
      type: "boolean",
      name: "visible",
      initialValue: false,
    },
  ],
}

Then on the frontend, you can slice the block array between these markers and wrap them in section elements. The thread includes complete code examples for processing these ranges.

3. Flatten your structure – instead of nesting, use a single portable text array with custom styling

4. Use separate fields – if sections represent distinct content areas, they might be better as separate top-level fields

5. Custom input component (not recommended) – You could build a completely custom input component that renders nested portable text editors, but this is complex, fragile, and you'd be fighting against the framework. You'd need to manage state, focus, and synchronization yourself.

The preview + modal editing pattern (default behavior for block objects) is the officially recommended approach for block objects containing portable text. The separator/preprocessing approach is a clever workaround that keeps the editing experience simple while achieving your desired frontend structure.

Putting a portable text editor inside of a portable text editor is not recommended, as it drastically increases your attribute count. I’d suggest using marks or annotations to indicate sections instead.
Thanks. I don't think annotations can be applied across blocks though, and marks are a bit too generalized.
I decided to just use a hidden separator that i use to tag and slice block arrays on the frontend in a preprocessing step, which I wrap into sections. Easy peasy.
import SectionRule from "@/src/components/studio/block/SectionRule";
import { defineType } from "sanity";
import { BlockElementIcon } from "@sanity/icons";

export default defineType({
  title: "Section Rule",
  type: "object",
  name: "sectionRule",
  description:
    "A rule that wraps content into sections. Content must be nested between rules to be wrapped. Rules can optionally be set to render on the frontend.",
  icon: BlockElementIcon,
  fields: [
    {
      title: "Visible",
      type: "boolean",
      name: "visible",
      initialValue: false,
      description: "Sets whether this rule will be rendered on the frontend.",
    },
  ],
  preview: { select: { visible: "visible" } },
  components: { preview: SectionRule },
});

import { PreviewProps } from "sanity";

export default function SectionRule(props: PreviewProps) {
  const { visible } = props as PreviewProps & { visible: boolean };

  const opacity = visible ? "opacity-100" : "opacity-20";
  const width = visible ? "border-2" : "border";

  return <hr className={[opacity, width].join(" ")} />;
}
For anyone interested:
import { PortableTextBlock } from "next-sanity";

/**
 * Wraps multiple ranges of a block array with pseudo blocks.
 * @param blocks Input block array
 * @param ranges An array of inclusive index tuples
 * @param properties The pseudo block that wraps each range
 * @returns Processed block array
 */
function wrapBlocks(
  blocks: PortableTextBlock[],
  ranges: [number, number][],
  properties: { _type: string; _key?: string }
): PortableTextBlock[] {
  const result = [];
  let marker = 0;

  for (const [from, to] of ranges) {
    result.push(...blocks.slice(marker, from));
    result.push({ ...properties, children: blocks.slice(from, to + 1) });
    marker = to + 1;
  }

  result.push(...blocks.slice(marker));
  return result;
}

/**
 * Wraps groups of block array elements in a section block.
 * @param blocks Input block array
 * @param ranges An array of inclusive index tuples
 * @returns Processed block array
 */
export function wrapSections(
  blocks: PortableTextBlock[],
  ranges: [number, number][]
): PortableTextBlock[] {
  return wrapBlocks(blocks, ranges, { _type: "section" });
}

/**
 * Checks a block array for blocks that pass a predicate function
 * @param blocks Input block array
 * @param predicate A function that tests each block
 * @returns An array of indexes of passing blocks
 */
function getBlockIndexes(
  blocks: PortableTextBlock[],
  predicate: (block: PortableTextBlock) => boolean
): number[] {
  const indexes: number[] = [];
  blocks.forEach((block, index) => {
    if (predicate(block)) indexes.push(index);
  });
  return indexes;
}

/**
 * Extract ranges of blocks to wrap in section tags
 * @param blocks
 * @returns
 */
export function getSectionRanges(
  blocks: PortableTextBlock[]
): [number, number][] {
  const indexes = getBlockIndexes(
    blocks,
    (block) => block._type === "sectionRule"
  );
  const ranges: [number, number][] = [];

  if (indexes.length === 0) {
    return ranges;
  }

  let marker = indexes[0];

  for (let i = 1; i < indexes.length; i++) {
    ranges.push([marker + 1, indexes[i] - 1]);
    marker = indexes[i];
  }

  return ranges;
}
There's probably some library that does this already for portabletext. Haven't really bothered to search it out though.

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?