Rendering PortableText from scratch

A walkthrough of my thought process for creating a PortableText component for Svelte with 0 dependencies.

TL;DR: if you're in need of a PortableText renderer for Svelte, check out the Github repo with the final code. If you'd like to get an insight on how the sausage was made, follow along 😊

Context

PortableText (PT) is a JSON-based rich-text format, heavily used in Sanity.io, the creators of the technology. If you've ever had to migrate WordPress posts, extract insights from HTML or equip CMS editors with custom components for their pages, PortableText will feel like a breath of fresh air.

The rest of this guide assumes some familiarity with it.

Usually, when you want to render PortableText in your front-end(s), you reach to one of the official packages such as @sanity/block-content-to-react or @sanity/block-content-to-html. When your the medium/framework you're developing in doesn't have a solid alternative for rendering, though, things can get complicated.

I'm currently building a community-driven recipes website with my wife. It's one of those projects where I give myself the luxury of creating everything from scratch to learn and challenge myself. We're using Svelte, which has no stable PortableText renderer - although MovingBrand's package and Rune's svelte-pote are worth mentioning (thanks for the effort, y'all!), they aren't super mature yet.

Knowing that, I was a reluctant from using PT-based content in the app as I knew I'd had to hand-roll my own implementation, but figured it'd be a good flexer of my understanding of the format.

In the past I've built a handful of Svelte-powered static websites using @sanity/block-content-to-html and rendering them as HTML with {@html}. It was messy, but it worked for static components. As now I need dynamic functionality such as inserting the logged user's name inside the text, this wasn't an option.

Creating our renderer

Let's take the start of this article as an example of PortableText data:

const example = [
  {
    // Rich text block (paragraph/header/list/quote)
    _type: "block",
    _key: "2ca469a56edd",
    // Block type: "paragraph"
    style: "normal",
    // Marks enrich text with custom data or formatting, defined here
    markDefs: [
      {
        _type: "blockAbsUrl",
        _key: "06d4cabb8fc8",
        url: "https://www.npmjs.com/package/@sanity/block-content-to-react",
        newWindow: true,
      },
    ],
    children: [
      {
        _type: "span",
        _key: "59e1710cc3f8",
        text: "Usually, when you want to render PortableText in your front-end(s), you reach to one of the official packages, ",
        marks: [],
      },
      {
        _type: "span",
        _key: "asdoih123893f8",
        text: "such as ",
        // Marks can be about formatting, such as strong, em, code, etc.
        marks: ["em"],
      },
      {
        _type: "span",
        _key: "4792e1707c91",
        text: "@sanity/block-content-to-react",
        // Or you can use your own markDefs, such as a link
        // Refers to the mark definition above (blockAbsUrl)
        marks: ["06d4cabb8fc8"],
      }
    ],
  },
  {
    // Custom component type:
    _type: "callout",
    _key: "8d196ce704a5",
    title: "Context",
    // Nested PortableText:
    body: [
      {
        _type: "block",
        _key: "6ec552e3531b",
        // ...
      },
    ],
  },
];

Its base structure is an array of objects, each containing a _key and _type.

Rendering custom blocks

We can have custom components/blocks with their own data structure, such as the callout which has a title and a body, which is its own PortableText instance. This is a solid place to start our renderer:

<!-- PortableText.svelte -->
<script>
  export let blocks = []
  export let serializers = undefined
</script>

{#each blocks as block, index (block._key)}
  {#if serializers?.types?.[block._type]}
    <!-- Custom block-level element -->
    <svelte:component
      this={serializers.types[block._type]}
      {block}
      {index}
      {blocks}
    />
  {:else}
    <!-- Block _type not yet supported -->
  {/if}
{/each}

In the PortableText.svelte component above, we're mapping over all blocks and rendering a Svelte component for them based on their _type. The serializers property is an object that takes components for rendering custom marks and types - here's an example of that in action:

<script>
  import PortableText from '../PortableText/PortableText.svelte'
  import AbsoluteURL from '../PTElements/AbsoluteURL.svelte'
  import Callout from '../PTElements/Callout.svelte'

  export let article
</script>

<PortableText
  blocks={article.body}
  serializers={{
    // Rendering the Callout component for the callout block _type
    types: {
      callout: Callout,
    },
    marks: {
      absUrl: AbsoluteURL,
    },
  }}
/>

If we render the above for the example data, we'll get a Callout rendered 🎉

Rendering rich text

It's a good start, but to get the full content we'll need to render entries of _type: "block", and this is where things start getting a bit complicated. PortableText blocks have the following complexities:

  • They have multiple children, each which have their own marks
    • Marks can set formatting (marks: ["strong", "em"] for an italicized, bold text)
    • Or they can refer to a custom mark defined in the block's markDefs (marks: ["06d4cabb8fc8"], where this id refers to a mark definition's _key.
    • marks: ["strong", "06d4cabb8fc8"] for a bold link, for example
    • This means a single text property can be wrapped in multiple elements (<strong><em><a>Click me!</a></em></strong>)
  • Children also have their _types
    • Regular text is of _type: "span"
    • But we can also add custom inline-blocks, such as _type: "userInfo" for displaying the user's name and avatar in the middle of the text
  • Their style
    • The normal style is the regular <p> element in HTML land
    • h1-h6 and blockquote are also straightforward
    • But styles can be customized. For example, I often use textCenter in my projects for a centralized paragraph.
  • The level and listItem properties set their list indentation
    • This means there is no "parent bullet" containing its children items, which would be easier to reason about.
Note

As I still didn't face the need for lists in my project's rich content, I won't cover them (yet?)

Let's start by creating a BlockRenderer component and plugging it in into PortableText:

{#each blocks as block, index (block._key)}
  {#if serializers?.types?.[block._type]}
    <!-- Custom block-level element -->
    <svelte:component
      this={serializers.types[block._type]}
      {block}
      {index}
      {blocks}
    />
  {:else if block._type === "block"}
    <BlockRenderer {blocks} {index} {block} {serializers} />
  {:else}
    <!-- Block _type not yet supported -->
  {/if}
{/each}

Aside from the wrapper element, BlockRenderer is very similar to PortableText: it gets an array of entries (in this case block.children) and renders components for each based on their _type. For now, let's wrap every block in a paragraph - we'll deal with styles later:

<!-- BlockRenderer.svelte -->
<script>
  import BlockSpan from './BlockSpan.svelte'
  import type { PTBlock, Serializers } from './ptTypes'

  export let index
  export let blocks
  export let block
  export let serializers
</script>

<p>
  {#each block.children as child (child._key)}
    {#if serializers?.types?.[child._type]}
      <!-- Custom inline element -->
      <svelte:component
        this={serializers.types[child._type]}
        {block}
        node={child}
      />
    {:else if child._type === 'span'}
      <!-- Regular span / text child -->
      <BlockSpan span={child} {block} {serializers}>{child.text}</BlockSpan>
    {:else}
      <!-- Unsupported child _type -->
    {/if}
  {/each}
</p>

This is enough for custom inline components. The BlockSpan requires a bit more work.

Before proceeding, notice how in the code above we're adding {child.text} to the <BlockSpan>'s content. This is the actual textual value users will see, and it'll be exposed inside BlockSpan as a <slot>, which is very similar to React's props.children (more on Svelte slots).

As mentioned above, we can have multiple marks per _type: "span", so we'll need to do a bit of recursion in order to properly render the full span. In the BlockSpan implementation below, notice how each instance of it is focused solely on one mark and recursively renders another child BlockSpan with the remaining marks (nestedSpan):

<!-- BlockSpan.svelte -->
<script lang="ts">
  export let block
  export let span
  export let serializers

  $: allMarks = span.marks || []

  // Let's start with the first mark
  $: currentMark =
    // If the mark references an entry in markDefs, use that object as the currentMark
    block.markDefs.find((def) => def._key === allMarks[0]) || allMarks[0]

  // If we have more marks, we'll render a nested BlockSpan with remaining marks
  $: nestedSpan = {
    ...span,
    marks: allMarks.slice(1),
  }

  $: customComponent = serializers?.marks
    ? typeof currentMark === 'string'
      ? serializers.marks[currentMark]
      : serializers.marks[currentMark?._type]
    : undefined
</script>

{#if !currentMark}
  <!-- If no current mark, render only the text without wrapping elements -->
  <slot />
{:else if customComponent}
  <svelte:component this={customComponent} {block} {span} mark={currentMark}>
    <!-- Inside the custom component, render recursive BlockSpan with remaining marks -->
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </svelte:component>
{:else if currentMark === 'strong'}
  <strong>
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </strong>
{:else if currentMark === 'em'}
  <em>
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </em>
{:else if currentMark === 'code'}
  <code>
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </code>
{:else if currentMark === 'underline'}
  <u>
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </u>
{:else if currentMark === 'strike-through'}
  <s>
    <svelte:self {block} span={nestedSpan} {serializers}>
      <slot />
    </svelte:self>
  </s>
{:else}
  <!-- Unsupported mark _type - let's render the plain text -->
  <slot />
{/if}

With the above, we have rich text with proper formatting and inline notations in a customizable way! Say we want to add custom classes to bold text - the default is a plain <strong> element as you can see above -, we'd do the following with <PortableText>:

<script>
  import PortableText from '../PortableText/PortableText.svelte'
  import AbsoluteURL from '../PTElements/AbsoluteURL.svelte'
  import CustomStrong from '../PTElements/CustomStrong.svelte'

  export let article
</script>

<PortableText
  blocks={article.body}
  serializers={{
    marks: {
      // Custom mark
      absUrl: AbsoluteURL,
      // Custom component for format-only mark
      strong: CustomStrong
    },
  }}
/>

Here's what CustomStrong could look like:

<!-- CustomStrong.svelte -->
<strong class="text-gray-900 font-black">
  <!-- Emojis for extra boldness 💥 -->
  👉 <slot /> 👈
</strong>

Aside from not handling lists, the one issue with this PortableText renderer is that every block will be rendered inside a paragraph, even when its style is a heading, quote, etc. Let's fix that by replacing the wrapping <p> in BlockRenderer.svelte with a new component, BlockWrapper:

<!-- Subset of BlockRenderer.svelte -->
<BlockWrapper {block} {serializers} {index} {blocks}>
  {#each block.children as child (child._key)}
    <!-- rendering logic (see previous code example) -->
  {/each}
</BlockWrapper>

BlockWrapper is very similar to BlockSpan in the sense that its sole purpose is to wrap its children in appropriate tags. The difference is that it doesn't need the complicated recursion we saw above as each block can only have one style. The result is a much simpler component:

<script>
  import type { PTBlock, Serializers } from './ptTypes'
  import ReportError from './ReportError.svelte'

  export let index
  export let blocks
  export let block
  export let serializers

  $: style = block.style || 'normal'

  $: customStyle = serializers?.blockStyles?.[style] || undefined
</script>

{#if customStyle}
  <svelte:component this={customStyle} {block} {index} {blocks}>
    <slot />
  </svelte:component>
{:else if style === 'h1'}
  <h1><slot /></h1>
{:else if style === 'h2'}
  <h2><slot /></h2>
{:else if style === 'h3'}
  <h3><slot /></h3>
{:else if style === 'h4'}
  <h4><slot /></h4>
{:else if style === 'h5'}
  <h5><slot /></h5>
{:else if style === 'h6'}
  <h6><slot /></h6>
{:else if style === 'blockquote'}
  <blockquote><slot /></blockquote>
{:else if style === 'normal'}
  <p><slot /></p>
{:else}
  <!-- Unsupported style - let's render it inside a paragraph to prevent hiding content -->
  <p>
    <slot />
  </p>
{/if}

Similarly to custom types and marks, some times we want to render styles differently. In order to deal with that, the serializers object can also take blockStyles:

<script>
  import PortableText from '../PortableText/PortableText.svelte'
  import CustomHeading from '../PTElements/CustomHeading.svelte'
  import CentralizedText from '../PTElements/CentralizedText.svelte'

  export let article
</script>

<PortableText
  blocks={article.body}
  serializers={{
    blockStyles: {
      // Custom heading 1
      h1: CustomHeading,
      // Custom user-defined style
      textCenter: CentralizedText
    },
  }}
/>

And, as we've passed index and blocks properties to our custom style components, our CustomHeading can adapt its styles based on other blocks near it - what is often called rule-based design:

<!-- CustomHeading -->
<script>
  export let index
  export let blocks
  export let block

  const HEADING_STYLES = ["h1", "h2", "h3", "h4", "h5"]
  $: style = block.style
  $: precededByHeading = HEADING_STYLES.includes(blocks[index - 1]?.style)

  $: anchorId = `heading-${block._key}`
</script>

<!-- If preceded by heading, have a higher margin top -->
<div class="relative {precededByHeading ? "mt-10": "mt-4"}" id={anchorId}>
  <a href="#{anchorId}">
    <span class="sr-only">Link to this heading</span>
    🔗
  </a>
  {#if style === "h1"}
    <h1 class="text-4xl font-black"><slot/></h1>
  {:else if style === "h2"}
    <h2 class="text-3xl"><slot/></h2>
  {:else}
    <h3 class="text-xl text-gray-600"><slot/></h3>
  {/if}
</div>

The example above is a bit underwhelming, but imagine sticking a CTA next to a contact form if they come together; or creating a grid of images if two or more small images follow each-other; or putting testimonials on top of their preceding case studies... the sky is the limit!

I hoped this served to shine some light on PortableText and make it less of a black box for you. You can find the full source code here. Glad to take contributions and feedback!