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, the official @portabletext/svelte package is the way to go. The minimal renderer I built from the approach below can be found in this this Github repo 😊
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 ownmarks
- 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>
)
- Marks can set formatting (
- Children also have their
_type
s- 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
- Regular text is of
- Their
style
- The
normal
style is the regular<p>
element in HTML land h1-h6
andblockquote
are also straightforward- But styles can be customized. For example, I often use
textCenter
in my projects for a centralized paragraph.
- The
- The
level
andlistItem
properties set their list indentation- This means there is no "parent bullet" containing its children items, which would be easier to reason about.
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!