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.

Andy Thompson

06 October 2023

7 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:

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 {
  nodeParse,
  transformToPortableText,
  IPortableTextItem,
} 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.

Picture of Luminary CTO Andy smiling with a black background

Andy Thompson

CTO, Kontent.ai MVP, Kentico MVP, Owner

As our CTO, Andy heads up our developer teams, platforms and technology strategy.

Emmanuel Tissera

Emmanuel Tissera

Technical Director, Umbraco MVP

As Technical Director, Emmanuel works closely with our clients while providing leadership and mentoring to the development team.

Ynze Nunnink

Ynze Nunnink

Senior Developer/Optimizely MVP

Ynze is an experienced developer who specialises in .NET and Optimizely.

Everything you need to know if you're considering migrating your website from a traditional CMS to a headless CMS.

8 min read

Is a headless CMS good or bad for SEO?

Using a headless CMS for a website isn't going to hurt your SEO. It sure doesn't guarantee good results, but when implemented properly, it can be unbeatable.

Andy Thompson
Andy Thompson

19 November 2020

8 minute read

Kentico Kontent

8 min read

What is Kentico Kontent? And why should I care?

Rather than being a new version of Kentico CMS or EMS, Kentico Kontent is an entirely new product from Kentico. As such, it requires a little explanation!

Andy Thompson
Andy Thompson

20 November 2016

8 minute read

State of Jamstack Report 2021

6 min read

The State of the Jamstack in 2021

For the second year running, Luminary has partnered with Kentico Kontent to produce The State of the Jamstack report. Here are our thoughts one year on.

Andy Thompson
Andy Thompson&Adam Griffith

08 June 2021

6 minute read

DDD Melbourne stage with Luminary slide as backdrop

1 min read

The heads up on headless - slides from DDD Melbourne 2019

I had the honour of presenting a session on Headless CMS and Content as a Service (CaaS) at DDD Melbourne 2019 at the Melbourne Convention Centre. As promised, here are my slides from the event. I hope you enjoyed it!

Emmanuel Tissera
Emmanuel Tissera

10 August 2019

1 minute read

Kentico Kontent

10 min read

We built our new site using Kentico Cloud (and a few other microservices)

We've been preaching the benefits of headless CMS and microservices architecture to our clients for a couple of years now. So I decided it was time to put my money where my mouth is!

Andy Thompson
Andy Thompson

24 July 2018

10 minute read

The JAMstack - Jam on waffles

9 min read

Benefits of the Jamstack – from buzzword to business ready

The Jamstack is a modern approach to building fast, secure, and cost-effective websites and apps. In this article Luminary MD Adam Griffith explains the six 'Ss' of the Jamstack – Speed, Stability, Scalability, Security, Serviceability and Simplicity – to show why this movement is now business ready.

Adam Griffith
Adam Griffith

12 May 2020

9 minute read

5 min read

How do you handle online forms with Kontent.ai?

There are a few questions we regularly field when introducing the concept of a Content as a Service to people. After explaining the terms 'headless' and 'microservices', we invariably hit the topic of online forms - a staple feature of any traditional web CMS, but curiously absent from the feature list of your modern-day headless CMS.

Andy Thompson
Andy Thompson

03 June 2022

5 minute read

2 min read

Why it’s time for the Jamstack to shine – the technology perspective

Javascript, APIs and Markup (HTML) have been around for literally decades. So why the sudden wave of enthusiasm for Jamstack as an enterprise web development platform?

Andy Thompson
Andy Thompson

08 June 2020

2 minute read

UNICEF Australia

UNICEF Australia engaged Luminary to undertake a complete website rebuild, from discovery through to continuous improvement.

What is headless cms

4 min read

Serving content to IoT devices with a headless CMS

Need a way to serve the same content to a growing range of platforms? Headless content management systems are your answer.

Emmanuel Tissera
Emmanuel Tissera

10 October 2019

4 minute read

Headless CMS expert panel event at Luminary

11 min read

Getting your head around headless content management

On Wednesday 31 July 2019 we hosted an expert panel discussion at Luminary, featuring representatives from leading Headless CMS vendors. By popular demand, here is the video! And as an added bonus, I reached out to the panelists for their thoughts on the remaining audience questions we didn't have time for. Enjoy!

Andy Thompson
Andy Thompson

22 August 2019

11 minute read

2 min read

A tutorial on building a .NET Core website with Umbraco Headless

Want to build a .NET Core MVC website using Umbraco Headless? Follow this tutorial from Luminary Technical Lead Emmanuel Tissera...

Emmanuel Tissera
Emmanuel Tissera

12 October 2018

2 minute read

Cute dog looking wistful

Pet Culture

Backed by Woolworths and pet insurance specialists PetSure, PetCulture is a new online destination for pet owners.

Umbraco Heartcore - Heart on a string

6 min read

Umbraco Heartcore launch

Umbraco Heartcore (formerly Umbraco Headless) was announced for public beta in February 2018 at the Umbraco Down Under Festival held on the Gold Coast in Australia. Fast forward to December 2019 for the commercial launch and learn more about what Umbraco Heartcore has to offer.

Emmanuel Tissera
Emmanuel Tissera

02 December 2019

6 minute read

legalsuper

legalsuper approached Luminary following a significant brand refresh and was faced with the predicament of redesigning and redeveloping their existing website or investing in a new build on a more suitable technical platform to suit their future needs.

3D cartoon image of a rocket launching

7 min read

We rebuilt our website on the Jamstack (without changing CMS)

You may not notice just by looking at it, but a few weeks ago we launched a completely rebuilt front end (or 'head') for our website. The new Jamstack tech is great, but the coolest part of all is that there was zero downtime, no content freeze, and no SEO impact (except positive!), as our existing headless CMS remained unchanged behind the scenes.

Andy Thompson
Andy Thompson

05 July 2022

7 minute read

Coloured cogs

8 min read

Why you should be modelling content

Content modelling is a critical part of setting a headless CMS up for success. Technical Director Emmanuel Tissera takes a deep dive into how it works.

Emmanuel Tissera
Emmanuel Tissera

22 April 2022

8 minute read

HealthyLife website

Healthylife

Healthylife – part of the Woolworths Group – is a digital startup that provides customers with health and wellness advice, services and products.

Screenshot of Kontent's Web Spotlight interface

4 min read

Web Spotlight - visual page editing in Kontent (in less than a day)

Web Spotlight is a brand new add-on for Kontent by Kentico that brings many of the features marketers miss from traditional web content management systems into their pure headless CaaS platform, including a tree-based navigation structure, real-time preview and in-context editing of pages. And the good news is, it is super easy to set up, regardless of what language you used to implement your website.

Andy Thompson
Andy Thompson

07 September 2020

4 minute read

optimizely-headless

5 min read

Optimizely as a headless CMS

The Optimizely CMS has always been known as a traditional CMS, but is this still the case? Newly added features are pushing it into headless territory, and they come with some competitive advantages.

Ynze Nunnink
Ynze Nunnink

17 March 2023

5 minute read

Composable DXP Umbraco

6 min read

Umbraco introduces composable DXP offering

Umbraco’s Composable DXP makes integrations simpler, easier and more intuitive.

Mario Lopez
Mario Lopez

30 June 2022

6 minute read

What is headless cms

7 min read

What is a composable DXP?

As enterprises increasingly look to adopt Headless CMS, best-of-breed SaaS services, and API-first microservices architectures, the role of the traditional monolithic Digital Experience Platform (DXP) is gradually being reduced in many cases. Yet the requirement for a full suite of complementary digital marketing and experience management services remains. Enter: the composable DXP.

Andy Thompson
Andy Thompson

11 May 2022

7 minute read

JAMstack simplicity

Jamstack

The Jamstack is a modern approach to building fast, secure, and cost-effective websites and apps. It combines the performance and security benefits of a static website, with the power and flexibility of headless CMS and a best-of-breed microservices architecture.

A robot sitting on a park bench reading a magazine

6 min read

AI-powered content recommendations with a headless CMS

While your headless CMS might not come with all the bells and whistles of a full-featured Digital Experience Platform (DXP), thanks to its API-first nature, it's relatively straightforward to integrate with the most powerful AI-powered content recommendations available on the market. We did just that with our own website, Kontent.ai, and Recombee.

Andy Thompson
Andy Thompson

23 August 2022

6 minute read

Fork in the road

7 min read

Choosing between Kentico Xperience and Kontent.ai

Most CMS or DXP vendors are synonymous with the products they produce, but Kentico Software took on a unique 'dual rail' product strategy, with two distinct flagship products serving different needs. What are the key differences, and when should you be looking at one or the other?

Andy Thompson
Andy Thompson

28 June 2022

7 minute read

8 min read

Configuring Web Spotlight in Kontent for any scenario

Web Spotlight for Kontent adds hierarchical page structure or navigation to the headless CMS - usually only seen in traditional Web Content Management Systems (WCMS). But if Kontent is headless, how can you configure it to match the way your website is designed and built?

Andy Thompson
Andy Thompson

14 May 2021

8 minute read

Keep Reading

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