Skip to main content Start reading the article

Rendering rich text with Astro and Storyblok

Published December 9, 2023

Storyblok provides an official integration for Astro (@storyblok/astro) that includes a renderRichText utility to render rich text. However, it has some limitations in mapping rich text elements to Astro components.

NordSecurity, the team behind NordVPN, bumped into these limits and created an alternative package called storyblok-rich-text-astro-renderer to address them.

The result is a library that provides a flexible way to customize the rendering behaviour and map elements like embedded Storyblok components.

For developers using bare Astro, it's quite a good option to render rich text from Storyblok.

Not quite there yet

As for me, I bumped into a whole different kind of issue. Specifically, Storyblok's default schema for converting JSON into HTML nodes:

  • The default rich text utility renders bold text with <b></b> and italic text with <i></i>, rather than using <strong></strong> and <em></em> respectively.

  • When adding an image to the Asset Manager, it's possible to specify an alt text and a caption, which are in turn converted to <img alt={} title={} > by the rich text renderer.

    I'd prefer to use <figure /> with <figcaption />.

  • Storyblok allows to specify 3 different kinds of links - i.e. email, internal link (a reference to a Storyblok story) or external link.

    I needed to add ref, and target attributes and specify a class for styling them in Astro.

While adding custom components is well-explained, I found the documentation for customizing the schema a bit insufficient.

Storyblok's JS library adopts several different strategies which sometimes prevent the injection of new tags. For this reason, I thought it could be worth writing a quick piece, providing some extra examples.

Implementation

As a reference, these are the most relevant files in the Storyblok JS Client library:

Assuming you already followed the official guide, you only need to provide a custom schema to the renderRichText() function:

import { renderRichText } from "@storyblok/astro";
import { myRichTextSchema } from "../utils/myRichTextSchema";

const { blok } = Astro.props;

const renderedRichText = renderRichText(blok.content, {
  schema: myRichTextSchema(),
});
---

<div set:html={renderedRichText} />

Create the myRichTextSchema.js file with the following content:

import { RichTextSchema } from "@storyblok/astro";
import cloneDeep from "clone-deep";

function escapeHtml(unsafe) {
  return unsafe
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

export function myRichTextSchema() {
  let mySchema = cloneDeep(RichTextSchema);

  mySchema.marks.bold = function () {
    return {
      tag: "strong",
    };
  };

  mySchema.marks.italic = function () {
    return {
      tag: "em",
    };
  };

  mySchema.marks.link = function (node) {
    if (!node.attrs) {
      return {
        tag: "",
      };
    }

    const attrs = { ...node.attrs };
    const { linktype = "url" } = node.attrs;
    delete attrs.linktype;

    if (attrs.href) {
      attrs.href = escapeHtml(node.attrs.href || "");
    }

    if (linktype === "email") {
      attrs.href = `mailto:${attrs.href}`;
    }

    if (attrs.anchor) {
      attrs.href = `${attrs.href}#${attrs.anchor}`;
      delete attrs.anchor;
    }

    if (linktype === "url") {
      attrs["ref"] = "noopener nofollow";
      attrs["target"] = "_blank";
      attrs["class"] = "link--ext";
    }

    if (attrs.custom) {
      for (const key in attrs.custom) {
        attrs[key] = attrs.custom[key];
      }
      delete attrs.custom;
    }

    // Default Storyblok schema seems to forget about these
    if (attrs.linktype) {
      delete attrs.linktype;
    }

    if (attrs.story) {
      delete attrs.story;
    }

    if (attrs.uuid) {
      delete attrs.uuid;
    }

    return {
      tag: [
        {
          tag: "a",
          attrs: attrs,
        },
      ],
    };
  };

  mySchema.nodes.image = function (node) {
    return {
      html: `
        <figure>
          <img
            ${node.attrs.src ? 'src="' + node.attrs.src + '"' : ""}
            ${node.attrs.alt ? 'alt="' + node.attrs.alt + '"' : ""}
            loading="lazy" 
            style="max-width: 100%; height: auto;"
          >
          ${node.attrs.title ? '<figcaption>' + node.attrs.title + '</figcaption>' : ""}
        </figure>
      `
    }
  }

  return mySchema;
}

schema.marks.bold and schema.marks.italic are quite trivial. We simply replace b and i with the tags we want to use.

schema.marks.link is slightly more complex since it needs to render three different types of links, as explained previously. For convenience, I re-implemented the original escapeHtml function.

schema.nodes.image by default uses a singleTag value for rendering.

We could have used tag which can also be defined as an array of objects, and defined figcaption as a child. However, there would be no way to add the caption text as innerHTML. The most trivial way to workaround this is to use html instead.