Working Effectively with a Headless CMS in React

Headless CMSes are amazing tools that empower teams in creating and managing content. Besides all the benefits of traditional CMSes, they also provide greater flexibility for designers, content creators and developers by decoupling content from its presentation.

All this flexibility, however, comes with a price: when coding a part of the application that’s not tied to a CMS, most, if not all, components that make up that part are known at compile time, and thus are, in this sense, static. In contrast, crafting the presentation for content originating from a CMS requires a much more dynamic approach as the very components that make up this presentation are only determined at runtime.

Although React is incredibly well suited to deal with this scenario where composability is a key factor, in my time dealing with Headless CMSes I learned that there are several pitfalls that are easy to fall into.

In these posts, I’m going to present a series of battle-tested patterns to work effectively with Headless CMSes in React while keeping your codebase maintainable.

Table of Contents

This post is part of a series:
Part 1 -> You are here
Part 2 -> https://blog.codeminer42.com/working-effectively-with-a-headless-cms-in-react-part-ii/
Part 3 -> https://blog.codeminer42.com/working-effectively-with-a-headless-cms-in-react-part-iii/

Setting the Stage

First things first:

This series is not intended to be an introduction to Headless CMSes, but rather as a concrete guide on how to work effectively with them, so as a pre-requisite, you should already know what a CMS is, what a Headless CMS is, and ideally have already dipped your toes in its waters.

In the upcoming exposition, we’ll present a sequence of problems that arise when working with Headless CMSes in React, along with patterns that address them.

To aid us in this presentation we’ll pretend we’ve been tasked with implementing the components for a Headless CMS using React.

Both the problems and their corresponding solutions will build upon this enactment so that we can understand them in a pragmatic and concrete way.

As we intend to approach these problems more generically, we won’t use any specific Headless CMS for our examples.

Also, the forementioned problems will be presented in the order they usually appear during development. As they pile up and grow in complexity, the patterns that address them get increasingly more sophisticated.

So keep in mind that you don’t necessarily need to apply the most sophisticated patterns right away, as you might not have the problems that warrant their application. Instead, you should look for the simplest patterns that solve the problems you currently have and then resort to more sophisticated patterns only when the need arises.

Lastly, all the code you’ll find in this post was extracted from this repository, where for each problem and pattern that we discuss here, there’s a branch that contains working code with the examples and pull requests pointing to the previous branch to make it easy to see the changes.

Warming Up

To warm up, let’s do a brief high-level overview of the workflow associated with a Headless CMS.

Let’s say we’re working on an application where we’ll be aiding the content team in creating landing pages.

To do that, first, developers, designers and content creators will collaborate to define how these pages are going to be structured in terms of which building blocks will be available and in what ways they can be composed to build these pages.

Once done, these building blocks are encoded as some form of schema in the CMS, which dictates what components are available for the content creators and where they can be used. At the same time, it determines the shape of the data that will be sent to the application that will build the presentation for that data.

Then, content creators create the landing pages using the CMS interface, and we developers fetch a representation (e.g. JSON) of these pages from some API and use it to build the pages themselves in the application.

Now, let’s see a concrete example of what this scenario could look like in practice:

The very first thing we need to know is: what is the schema for these pages, that is, what is the shape of the data that we’ll be getting from the CMS API.

In the Headless CMS interface, this schema is represented as some sort of set of components/blocks along with their fields and constraints (e.g. their data types).

For us developers, this schema may be represented in terms of types, which is what we’ll do here.

It’s worth mentioning that many times Headless CMSes provide an SDK that auto-generates these types for you based on the existing CMS components. However, even if this is not the case, it’s very much worth it to define these types yourself.

So here’s the schema for the Headless CMS components in our make-believe scenario:

export type LandingPage = {
  id: string;
  component: "LandingPage";
  hero: Hero;
  content: Array<Content>;
};

export type Hero = {
  id: string;
  component: "Hero";
  headline: string;
  body: string;
};

export type Content = {
  id: string;
  component: "Content";
  headline: string;
  body: string;
};

This schema tells us a few things:

  • There are three components available: LandingPage, Hero and Content.
  • A LandingPage has two slots: a slot for a Hero component and a slot for a list of Content components.
  • A Hero has two fields: a headline and a body which are both text fields.
  • A Content has two fields: a headline and a body which are both text fields.

Now let’s say content creators create the home page (/) using these components.

When we fetch the data for this page, assuming it’s using a JSON representation, it would be something like this:

{
  "id": "58d93c62-1aed-4174-853e-839f859790fb",
  "component": "LandingPage",
  "hero": {
    "id": "f743fb69-8de5-46f4-9848-a359f58e7941",
    "component": "Hero",
    "headline": "The product you were looking for!",
    "body": "We have just the product you need. It's the best product ever. You will love it!"
  },
  "content": [
    {
      "id": "347c0ac9-3cfe-4a72-8112-1f594967eaa7",
      "component": "Content",
      "headline": "Advantages",
      "body": "We listen to our customers and provide the best products."
    },
    {
      "id": "e0d5a9f7-6f3d-4c7d-9b8b-3b4e5a5d6f3d",
      "component": "Content",
      "headline": "Our Mission",
      "body": "Our mission is to provide the best products."
    }
  ]
}

In this initial scenario, we’ll start with an approach where for each CMS component we’ll create a corresponding React component:

Hero

// We're assuming the schema (types) is being auto-generated
// and as such, it usually generates types
// with the SAME name as the component itself,
// thus we need to do this renaming.
// But if you're creating these types yourself,
// you can just declare them inline with the component
import { Hero as HeroProps } from "@/infrastructure/cms/schemas";
import cx from "./Hero.module.scss";

export const Hero = ({ headline, body }: HeroProps) => {
  return (
    <div className={cx.container}>
      <h2 className={cx.headline}>{headline}</h2>

      <p className={cx.body}>{body}</p>
    </div>
  );
};

Content

import { Content as ContentProps } from "@/infrastructure/cms/schemas";
import cx from "./Content.module.scss";

export const Content = ({ headline, body }: ContentProps) => {
  return (
    <div className={cx.container}>
      <h3 className={cx.headline}>{headline}</h3>

      <p className={cx.body}>{body}</p>
    </div>
  );
};

LandingPage

import { LandingPage as LandingPageProps } from "@/infrastructure/cms/schemas";
import { Hero } from "./Hero";
import { Content } from "./Content";

export const LandingPage = ({ hero, content }: LandingPageProps) => {
  return (
    <div>
      <Hero {...hero} />

      {content.map((item) => (
        <Content key={item.id} {...item} />
      ))}
    </div>
  );
};

We also need a "root" component that is going to possibly fetch and then pass the CMS data to the LandingPage. In this example, I’m going to use NextJS with the pages router:

type PageProps = {
  content: LandingPageProps;
};

export default function Page({ content }: PageProps) {
  return <LandingPage {...content} />;
}

// I'm using Static Site Generation
// as it pairs up very nicely with
// pages that are generated from CMS content
export const getStaticProps: GetStaticProps<
  PageProps,
  { slug: Array<string> }
> = async (context) => {
  const slug = context.params?.slug?.join("/") ?? "";
  const formattedSlug = `/${slug}`;

  const content = await fetchContent<LandingPageProps>(formattedSlug);

  return {
    props: {
      content,
    },
  };
};

And this is what it could look like:

(I know, not very pretty, but I’m not a designer =/)

Notice that currently there’s a single component available for each slot. There’s a single page template, which is the LandingPage. The LandingPage‘s hero slot only admits an instance of the Hero component ,and its content slot can only be populated by instances of the Content component.

Now that we have a starting point, we’ll explore a scenario where there are multiple components available for a given slot, granting much more flexibility for content creators.

Composing Components Strategically

Suppose that now designers and content creators agreed on having an additional content section that has an image in it.

This is what this new content section could look like:

Going back to the code, we need to make a few adjustments to accommodate this new component.

First, we need a way to differentiate between this new content section and the other one we already have.

To do that, we’re going to rename the former Content component to CommonContent and this new content section will be called MediaContent.

Keep in mind that to keep communication between developers, designers and content creators clear, it is wise to reflect these changes both in the CMS and in the designs as well.

After we make these changes, we re-generate schemas and we end up with this:

export type LandingPage = {
  id: string;
  component: "LandingPage";
  hero: Hero;
  // The content slot now accepts a list
  // of BOTH `CommonContent` and `MediaContent`
  // components
  content: Array<CommonContent | MediaContent>;
};

export type Hero = {
  id: string;
  component: "Hero";
  headline: string;
  body: string;
};

export type CommonContent = {
  id: string;
  component: "CommonContent";
  headline: string;
  body: string;
};

export type MediaContent = {
  id: string;
  component: "MediaContent";
  headline: string;
  media: Image;
};

export type Image = {
  id: string;
  component: "Image";
  src: string;
  alt: string;
  width: number;
  height: number;
};

There are four changes to the schema in regard to its previous version:

  1. The Content component was renamed to CommonContent.
  2. The introduction of the MediaContent component.
  3. The introduction of the Image component.
  4. The fact that LandingPage‘s content slot now also supports MediaContent instances.

Once again, for both MediaContent and Image, we implement React components that represent them:

MediaContent

import { MediaContent as MediaContentProps } from "@/infrastructure/cms/schemas";
import { Image } from "./Image";
import cx from "./MediaContent.module.scss";

export const MediaContent = ({ headline, media }: MediaContentProps) => {
  return (
    <div className={cx.container}>
      <h3 className={cx.headline}>{headline}</h3>

      <div className={cx.imageContainer}>
        <Image {...media} />
      </div>
    </div>
  );
};

Image

import { Image as ImageProps } from "@/infrastructure/cms/schemas";
import NextImage from "next/image";
import cx from "./Image.module.scss";

export const Image = ({ alt, src, height, width }: ImageProps) => {
  return (
    <NextImage
      className={cx.image}
      alt={alt}
      src={src}
      height={height}
      width={width}
    />
  );
};

Lastly, we need to modify the LandingPage React component to reflect the fact that its content slot now also supports MediaContent instances.

This is where things start to get a little bit tricky.

Previously, when LandingPage supported only CommonContent (which was previously named Content) instances, we knew what components to render in compile time.

Now, the actual component that is going to be rendered is only determined in runtime, because it is only after we have fetched the data from the API that we will know whether each content section is a CommonContent instance or a MediaContent instance.

Here’s a naive approach that deals with this problem:

import { LandingPage as LandingPageProps } from "@/infrastructure/cms/schemas";
import { Hero } from "./Hero";
import { CommonContent } from "./CommonContent";
import { MediaContent } from "./MediaContent";

export const LandingPage = ({ hero, content }: LandingPageProps) => {
  return (
    <div>
      <Hero {...hero} />

      {content.map((item) => {
        // We have to choose which component
        // is going to be rendered in RUNTIME
        // by checking the `component` field
        if (item.component === "CommonContent") {
          return <CommonContent key={item.id} {...item} />;
        }

        return <MediaContent key={item.id} {...item} />;
      })}
    </div>
  );
};

This approach works fine, but there’s a serious flaw in it, and to make it evident, we’ll look at another scenario that has an additional requirement.

Suppose that now there’s an additional page "template" we need to support, which is going to be called InfoPage.

Conceptually speaking, this InfoPage is very similar to the LandingPage, with the exception that it doesn’t have a hero, that is, it is composed solely of a list of content sections.

Here’s the updated schema:

export type LandingPage = {
  id: string;
  component: "LandingPage";
  hero: Hero;
  content: Array<CommonContent | MediaContent>;
};

export type InfoPage = {
  id: string;
  component: "InfoPage";
  content: Array<CommonContent | MediaContent>;
};

// Everything below here remains the same
// ...

And here’s the React component that represents the InfoPage:

import { InfoPage as InfoPageProps } from "@/infrastructure/cms/schemas";
import { CommonContent } from "./CommonContent";
import { MediaContent } from "./MediaContent";

export const InfoPage = ({ content }: InfoPageProps) => {
  return (
    <div>
      {content.map((item) => {
        if (item.component === "CommonContent") {
          return <CommonContent key={item.id} {...item} />;
        }

        return <MediaContent key={item.id} {...item} />;
      })}
    </div>
  );
};

Notice that for both LandingPage and InfoPage React components, we’re repeating the logic that decides which content component to render.

This is a DRY (Don’t Repeat Yourself) violation and it causes some problems, of which, the most treacherous one is that anytime we make additional varieties of content sections available to be used in these slots, we’ll have to remember to update both components, which could eventually lead to bugs if we ever forget to do so.

For example, suppose we add a CallToActionContent component that can also be used as a content section for both LandingPage and InfoPage. In that scenario, we’d have to update both places.

This problem also happens at the "root" component level, as now, we have to decide between rendering a LandingPage and a InfoPage in runtime:

type PageProps = {
  content: LandingPageProps | InfoPageProps;
};

export default function Page({ content }: PageProps) {
  if (content.component === "LandingPage") {
    return <LandingPage {...content} />;
  }

  return <InfoPage {...content} />;
}

So, to solve this problem, we’re going separate concerns by isolating the concern of choosing the right component.

To do that, we’re going to create a CmsComponent:

import {
  LandingPage as LandingPageProps,
  InfoPage as InfoPageProps,
  Hero as HeroProps,
  CommonContent as CommonContentProps,
  MediaContent as MediaContentProps,
  Image as ImageProps,
} from "@/infrastructure/cms/schemas";
import { LandingPage } from "./LandingPage";
import { InfoPage } from "./InfoPage";
import { Hero } from "./Hero";
import { CommonContent } from "./CommonContent";
import { MediaContent } from "./MediaContent";
import { Image } from "./Image";

export type CmsComponentProps =
  | LandingPageProps
  | InfoPageProps
  | HeroProps
  | CommonContentProps
  | MediaContentProps
  | ImageProps;

export const CmsComponent = (props: CmsComponentProps) => {
  const Component = componentMatrix[props.component];

  // We may also add some error handling
  // in case for any reason we receive a component
  // we're not expecting
  if (!Component) {
    console.error(`Component not found: ${props.component}`);

    return null;
  }

  // Unfortunately even if we made this component generic
  // Typescript wouldn't really recognize the fact that
  // the props we receive WILL match the selected component,
  // so we just cast away
  return <Component {...(props as any)} />;
};

const componentMatrix = {
  LandingPage: LandingPage,
  InfoPage: InfoPage,
  Hero: Hero,
  CommonContent: CommonContent,
  MediaContent: MediaContent,
  Image: Image,
} satisfies Record<CmsComponentProps["component"], unknown>;

This component receives the props that come from the CMS and from these props, it chooses the right component to be rendered and then forwards the props to it.

Now let’s refactor our components with this new component.

Landing Page

import { LandingPage as LandingPageProps } from "@/infrastructure/cms/schemas";
import { CmsComponent } from "./CmsComponent";

export const LandingPage = ({ hero, content }: LandingPageProps) => {
  return (
    <div>
      <CmsComponent {...hero} />

      {content.map((item) => (
        <CmsComponent key={item.id} {...item} />
      ))}
    </div>
  );
};

Info Page

import { InfoPage as InfoPageProps } from "@/infrastructure/cms/schemas";
import { CmsComponent } from "./CmsComponent";

export const InfoPage = ({ content }: InfoPageProps) => {
  return (
    <div>
      {content.map((item) => (
        <CmsComponent key={item.id} {...item} />
      ))}
    </div>
  );
};

MediaContent

import { MediaContent as MediaContentProps } from "@/infrastructure/cms/schemas";
import cx from "./MediaContent.module.scss";
import { CmsComponent } from "./CmsComponent";

export const MediaContent = ({ headline, media }: MediaContentProps) => {
  return (
    <div className={cx.container}>
      <h3 className={cx.headline}>{headline}</h3>

      <div className={cx.imageContainer}>
        <CmsComponent {...media} />
      </div>
    </div>
  );
};

And the "root" component:

type PageProps = {
  content: LandingPageProps | InfoPageProps;
};

export default function Page({ content }: PageProps) {
  return <CmsComponent {...content} />;
}

The other components are unchanged.

By isolating the logic that selects which component is going to be rendered within the CmsComponent, there are two huge benefits:

First, when dealing with the concern of what these components will look like, that is, their presentation, we don’t need to think about what are the possible components that we’ll have to render for each slot, we just delegate this responsibility to the CmsComponent.

Second, because this logic is centralized, anytime new components are added and become available as possible components for slots of other components, the only place we need to change is the CmsComponent.

For example, suppose that the media slot in the MediaComponent now needs to support a YoutubeVideo component, as content creators and designers want to also be able to embed videos in content sections.

It could look like something like this:

Updated schema:

// ...

export type YoutubeVideo = {
  id: string;
  component: "YoutubeVideo";
  videoId: string;
};

// ...

YoutubeVideo

import { YoutubeVideo as YoutubeVideoProps } from "@/infrastructure/cms/schemas";

export const YoutubeVideo = ({ videoId }: YoutubeVideoProps) => {
  return (
    <iframe
      width="560"
      height="315"
      src={`https://www.youtube.com/embed/${videoId}`}
      title="YouTube video player"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
      allowFullScreen
    ></iframe>
  );
};

Previously, we’d have to also update the MediaContent component (and any other components that could also render a YoutubeVideo in their slots) so that it would also consider the possibility that it would have to render a YoutubeVideo instead of an Image.

Now, the only place we have to change is the CmsComponent:

import {
  LandingPage as LandingPageProps,
  InfoPage as InfoPageProps,
  Hero as HeroProps,
  CommonContent as CommonContentProps,
  MediaContent as MediaContentProps,
  Image as ImageProps,
  YoutubeVideo as YoutubeVideoProps,
} from "@/infrastructure/cms/schemas";
import { LandingPage } from "./LandingPage";
import { InfoPage } from "./InfoPage";
import { Hero } from "./Hero";
import { CommonContent } from "./CommonContent";
import { MediaContent } from "./MediaContent";
import { Image } from "./Image";
import { YoutubeVideo } from "./YoutubeVideo";

export type CmsComponentProps =
  | LandingPageProps
  | InfoPageProps
  | HeroProps
  | CommonContentProps
  | MediaContentProps
  | ImageProps
  | YoutubeVideoProps;

export const CmsComponent = (props: CmsComponentProps) => {
  const Component = componentMatrix[props.component];

  if (!Component) {
    console.error(`Component not found: ${props.component}`);

    return null;
  }

  return <Component {...(props as any)} />;
};

const componentMatrix = {
  LandingPage: LandingPage,
  InfoPage: InfoPage,
  Hero: Hero,
  CommonContent: CommonContent,
  MediaContent: MediaContent,
  Image: Image,
  YoutubeVideo: YoutubeVideo,
} satisfies Record<CmsComponentProps["component"], unknown>;

Alternatively, if we were to add yet another content section component that could be rendered both by LandingPage and InfoPage, we’d still have to change only the CmsComponent, instead of duplicating logic in both of them.

Conclusion

In this post, we learned how to tackle rendering components that are are only determined at runtime, which is a common situation that arises when working with Headless CMSes.

In the next post we’ll talk about how to make our components reusable by decoupling the UI from the CMS and how to do code-splitting in such a dynamic environment.

We want to work with you. Check out our "What We Do" section!