A cartoon of a machine generating blocks of text and images

Resolving Rich Text in Astro and Next.js

A Headless CMS typically avoids storing your content using HTML. Instead, it uses 'Rich Text', enabling embedded components, and the ability to deliver that content anywhere, including mobile apps and other non-web channels.

Picture of Luminary CTO Andy smiling with a black background

By Andy Thompson, 6 October 20237 minute read

One of the benefits of using a Headless CMS is that your content is very cleanly structured for use in any channel, be it web, mobile, voice, or anything else you can think up. For this reason, a Headless CMS usually avoids using HTML code, preferring some form of 'rich text'. But what should you do when you inevitably do want to use it as HTML in your websites?

Rather than an HTML (or sometimes referred to as 'WYSIWYG') field, a Headless CMS will give you the option of storing content as 'rich text', which essentially means the opposite of 'plain text'. This means you have options such as bold, italic, or strikethrough, but also images, tables, hyperlinks, and even inline components such as code snippets, but you don't have the option of editing HTML code directly.

But HTML can already do all of this. So why the limitations around 'rich text'? There are two main answers:

  1. The web uses HTML, but other channels don't. Mobile apps, voice agents, documentation portals, search engines, and machine-learning models, these platforms could prefer to have direct access to your content and all its properties and links to other content items, rather than one big messy blob of HTML tags.
  2. HTML is messy anyway. A headless CMS (or, it could be argued, any modern CMS) should keep your content clean and tidy, and not get all mixed up with your branding, or how it should appear on any particular device (even if the web is your only channel). You want to be able to use this same content many times in the future, even after going through a redesign.

Why do we need to 'resolve' it?

Rich text is excellent, and not specific to one channel, but on the other hand, it's not supported directly by most channels either. We need to go from beautifully structured content that works perfectly in your chosen headless CMS, to whatever your preferred delivery channel requires, whether React components for your SPA, HTML for your website, or JSON for your federated search provider.

Screenshot of structured rich text content in Kontent.ai headless CMS

Go from this...

Screenshot of HTML content rendered out onto a web page

... to this.

Unfortunately, of course, different Headless CMS and other platforms all tend to implement rich text slightly differently. Some will use a reduced set of HTML with strict validation, some will use the very popular Markdown format, and many will use their own proprietary format in order to get the full power out of their own platform. And then they will all translate it into a format that can be delivered via their API, as part of a JSON response.

This situation where you come up with a new standard to fix your problem with all the other competing standards reminds me of one of my favourite XKCD comics:

XKCD comic: How Standards Proliferated

Source: XKCD https://xkcd.com/927/

Portable Text

But seriously... there is one standard we can use!Β 

To be clear, I'm not suggesting replacing HTML (well, not in this context anyway), Markdown, or JSON. Rather than a new standard to replace all the others like in that comic πŸ‘†πŸΌ, think of it as a universal adapter to put in the middle.

Portable Text is 'a JSON based rich text specification for modern content editing platforms'. Originally put forward by Headless CMS vendor Sanity.io, it was designed as 'an agnostic abstraction of rich text that can be serialized into pretty much any markup language'. In other words, you can convert any type of rich text into Portable Text, and then convert Portable Text into whatever any of your channels need.

Why put Portable Text in the middle?

With the proliferation of Headless CMS platforms, channels, rendering technologies, frameworks and libraries, the number of implementations of rich text renderers you need to build, maintain, debug, and upgrade when a new version comes out, expands exponentially if you want to connect all of them to each other. Part of the point of using a MACH architecture and Composable DXP is that you have your choice of the best tools for the job. Unfortunately, this can mean a lot of different integrations.

By putting one common standard in the middle, this reduces the number of implementations to simply one per platform or framework, or O(N) for the nerds in the audience πŸ€“. We only need to resolve from each CMS to Portable Text, and from Portable Text to each rendering technology.

And of course, as an added bonus, being a public standard, the best-case scenario is that we don't need to implement most of these at all, since it's highly likely there's an open-source package already available to do the job for you, as you'll see below!

Resolving Rich Text to Portable Text

Let's prove it! As an example, here's how you can resolve the content of a Rich Text Element in Kontent.ai into Portable Text, in a TypeScript (or JavaScript would be extremely similar).

import { nodeParse, transformToPortableText } from '@kontent-ai/rich-text-resolver';

const parsedTree = nodeParse(richTextElement.value);
const portableText = transformToPortableText(parsedTree);

Thanks to Kontent.ai's Rich Text Resolver package, that's it!

Packages exist to do a similar thing in most popular Headless CMS, such as Sanity.io of course (being the authors of the spec), or Contentful.

Then all that's left is resolving that Portable Text into the format you need for your delivery channel.

Resolving Portable Text in Astro

Astro (https://astro.build) is an exciting new web framework along the lines of Gatsby, Next.js, or Nuxt.js, with a focus on producing lightweight, high-performing websites.

If your rich text is fairly straightforward and doesn't include any complex data such as inline components, then it could be as simple as this:

import type { Elements } from '@kontent-ai/delivery-sdk';
import { PortableText } from "astro-portabletext";

import {
} from "@kontent-ai/rich-text-resolver";

interface Props {
    richTextInput: Elements.RichTextElement;

const { richTextInput } = Astro.props;

const richTextValue = richTextInput.value;
const parsedTree = nodeParse(richTextValue);
const portableText: IPortableTextItem[] = transformToPortableText(parsedTree);
<PortableText value={portableText} />

Out of the box, this will render out all the standard stuff you'd expect to see in a rich text element to HTML, such as bold, italic, unordered and ordered lists, hyperlinks, and various levels of headings.

It's very likely that you'll also have some enhancements you'll want to make on top of the default. This could be custom elements representing content types in your CMS, but also overriding the way standard elements such as tables or internal/external links are rendered. In order to do this, we simply define a components object:

import RichTextComponent from './rich-text/rich-text-component.astro';
import RichTextInternalLink from './rich-text/rich-text-internal-link.astro';
import RichTextTable from './rich-text/rich-text-table.astro';
import RichTextMark from './rich-text/rich-text-mark.astro';

const components = {
  type: {
    component: RichTextComponent,
    table: RichTextTable
  mark: {
    internalLink: RichTextInternalLink,
    sup: RichTextMark,
    sub: RichTextMark

...and pass that into the PortableText component:

<PortableText value={portableText} components={components} />

You only need to define/override those components where you're not happy with the default rendering you get 'out of the box' from the astro-portabletext package, so it keeps your code super neat and tidy.

Your implementations of the components you're overriding can also be nice and neat in their own Astro components. For example, this simple rich-text-mark.astro component to handle inline marks such as sub and sup in the code above:

const mark = Astro.props.node;

{(mark.markType === 'sup') && <sup><slot /></sup>}
{(mark.markType === 'sub') && <sub><slot /></sub>}

In the case of inline components (linked content items), image assets, or internal hyperlinks to other items in the CMS, depending on how fancy your rendering needs to be, we might need to pass through some extra information about those items, as there will only be references/pointers to them in the Portable Text that came out of Kontent.ai. These are available out of the Kontent.ai SDK as a Rich Text Element's linkedItems, links, and images properties.

The Portable Text specification allows us to add extra custom properties to the objects that represent our 'blocks' of rich text. So we can go ahead and enrich our Portable Text object with the relevant extra data out of the Kontent.ai Rich Text Element where appropriate:

const { richTextInput } = Astro.props;

const richTextValue = richTextInput.value;
const linkedItems = richTextInput.linkedItems;
const links = richTextInput.links;
const parsedTree = nodeParse(richTextValue);
const portableText: IPortableTextItem[] = transformToPortableText(parsedTree);

// define some extra fields to store custom data against portable text blocks
interface IEnrichedPortableTextComponent extends IPortableTextComponent {
    linkedItem: any

interface IEnrichedPortableTextInternalLink extends IPortableTextInternalLink {
    linkedItem: any

interface IEnrichedPortableTextTable extends IPortableTextTable {
    links: any

portableText.forEach((block) => {
    if (block._type === 'component') {
      // grab the associated linkedItem to go with this component
      const linkedItem = linkedItems.find(
        (item) => item.system.codename === block.component._ref
      (block as IEnrichedPortableTextComponent).linkedItem = linkedItem;
    else if (block._type === 'block') {
      // go through all the marks in this block to find internal links
      block.markDefs.forEach((mark) => {
        if (mark._type === 'internalLink') {
          // grab the associated contentItem this link points to
          const linkedItem = links.find(
            (item) => item.linkId === mark.reference._ref
          (mark as IEnrichedPortableTextInternalLink).linkedItem = linkedItem;
    else if (block._type === 'table') {
     // links can also be added inside table cells, so make them available to tables
     (block as IEnrichedPortableTextTable).links = links;

And that's it, job's done! Now we just need to implement those individual components to render out custom components, internal links, and tables.Β 

Internal links are super simple - we just render a hyperlink, and use the URL slug of the linkedItem we added to its definition.

For tables, we just render out our HTML table markup, and call the same PortableText component again inside each table cell.

For components, such as inline content items, it's surprisingly simple too! We can create a simple rich-text-component.astro component that checks the type of the linked item we provided to it, and chooses which Astro component we want to use to render out that specific item with all of its properties from the CMS:

import { contentTypes } from "../../models";
import LargeTile from "../large-tile.astro";
import CalendlyButton from "../calendly-button.astro";
import SomeOtherComponent from "../some-other-component.astro";

const linkedItem = Astro.props.node.linkedItem;

{linkedItem.system.type === contentTypes.large-tile.codename && <LargeTile data={linkedItem} />}
{linkedItem.system.type === contentTypes.calendly_button.codename && <CalendlyButton data={linkedItem} />}
{linkedItem.system.type === contentTypes.some-other-component.codename && <SomeOtherComponent data={linkedItem} />}

Resolving Portable Text in Next.js

For other popular web frameworks such as Next.js, the process is very similar.Β 

The resolution from Kontent.ai (or your chosen Headless CMS) to Portable Text is effectively identical.

Getting from there to your preferred framework, such as Next.js is equally straightforward, due to packages such as @portabletext/react.

Most of the work will be done for you. The only real difference in implementation is how you define your components object to pass through, defining customisations to the default rendering of your rich text, as Next.js needs React components. So building your components object might look more like this:

export const createPortableTextComponents = (linkedItems: IContentItem[], links: ILink[], images: IRichTextImage[]) => {
    return {
        types: {
            component: (portableTextBlock: any): JSX.Element => {
                const contentItem = linkedItems.find(
                    item => {
                        return item.system.codename === portableTextBlock.value.component._ref;
                return <RichTextComponent item={contentItem as IContentItem} />;
            table: (tableBlock: any): JSX.Element => {
                return <RichTextTable value={tableBlock.value} linkedItems={linkedItems} links={links} images={images} />;
            image: ({ value }: any): JSX.Element => {
                const image = images.find(
                    i => {
                        return i.imageId === value.asset._ref;
                return <NextImage src={value.asset.url} height={image?.height as number} width={image?.width as number} style={{ maxWidth: '100%', margin: '24px auto 24px', display: 'block', height: 'auto' }} alt="No alt provided" />;

There are pointers and examples available in the documentation for Kontent.ai's rich text resolver package.

Simplifying Rich Text Resolution

Resolving rich text from a Headless CMS is a logical step in modern web development, made straightforward by universally compatible standards like Portable Text. The abundance of open-source packages further eases this process, supporting various CMS and web frameworks, thus streamlining content delivery across diverse digital platforms.

Our headless CMS agency expertise

Our team of headless CMS experts is led by CTO Andy Thompson, a Kontent by Kentico MVP, and Technical Director Emmanuel Tissera who is an Umbraco MVP and a specialist in Umbraco Heartcore, Umbraco's headless CMS platform.

Keep Reading

Want more? Here are some other blog posts you might be interested in.