Working Effectively with a Headless CMS in React – Part II

Previously, we set the stage and learned how to tackle rendering components that are only determined at runtime. Now, we’re going to 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.

Table of Contents

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

Decoupling CMS From UI

Suppose that we now need to build a page for the application whose data does not come from the CMS, but rather from another source, like the backend.

Additionally, let’s say that for this page, we also want to use the very same components we’ve been using to build pages whose data do come from the CMS, like the LandingPage, the Hero, CommonContent, and so on.

Just to be clear, I’m not talking about a scenario where the backend will act as a CMS, but rather a scenario where we’re building a page where the components that are going to be rendered are "hardcoded", that is, they’re known in compile time and we’ll just fill some "gaps" in the page with the data that comes from backend.

In this scenario, we have a problem, as our components are tightly coupled to the CMS, and this coupling is both conceptual and practical.

It is conceptual in the sense that these components’ props are defined by the CMS itself and all these components know that the CMS exists.

It is practical in the sense that these components’ props include fields that are only relevant to the CMS and can only be provided by it.

In the current setting, the CMS components’ schemas are pretty slim, but you’ll find that in reality, they’re usually pretty bloated with lots and lots of properties that don’t make much sense from the UI perspective.

For instance, what is the purpose of the component prop, if not to inform us what specific CMS component will be occupying a certain slot?

If we were to use the LandingPage component, for instance, we’d be obliged to pass this useless component prop even though it contributes nothing to the UI itself.

Additionally, what if we wanted to pass a different component other than the Hero to the hero slot of the LandingPage? What if we had a different hero component that is not meant to be used in CMS pages, but that would work perfectly fine within LandingPage?

To solve these problems, we’ll decouple CMS components from UI components.

To do that, we’re going to split each React component into two components, one that is going to represent the CMS component and the other that is going to be responsible for actually implementing the UI.

For example, let’s start with the Hero component.

Currently, it looks like this:

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>
  );
};

Now, first, we’ll extract the UI component, whose sole responsibility is to take care of the presentation and thus it will know nothing about the CMS.

import cx from "./Hero.module.scss";

export type HeroProps = {
  headline: string;
  body: string;
};

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

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

Notice that now, this component is completely independent of the CMS. The props it receives are strictly the ones that make sense from a UI perspective.

And now, this component can be reused anywhere in our application, even outside the context of the CMS.

As for the CMS component, we’ll have a sort of an adapter component that will receive the props from the CMS, do whatever it needs to do in terms of deserialization, and then delegate the presentation itself to the UI component:

import { Hero as CmsHeroProps } from "@/infrastructure/cms/schemas";
import { Hero } from "../components/Hero";

// Notice that we now prefixed this component
// with `Cms` to differentiate it from the UI
// component
export const CmsHero = ({ headline, body }: CmsHeroProps) => {
  return <Hero headline={headline} body={body} />;
};

But what about those CMS components that have slots?

Let’s look at the LandingPage:

Once again, first, we’ll extract the UI component.

import { ReactNode } from "react";
import cx from "./LandingPage.module.scss";

export type LandingPageProps = {
  hero: ReactNode;
  content: ReactNode;
};

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

      {content}
    </div>
  );
};

Now there’s an interesting thing in this component, which is the fact that previously, it knew about the props of the components that went into its slots, but now, we’re taking a much more flexible approach, where it accepts any React element in its slots.

So, if we had, let’s say, a SpecialHero component that didn’t exist in the CMS and yet we wanted to pass it to the LandingPage outside of a CMS context, we could just do:

export const Page = () => {
  return (
    <LandingPage
      hero={<SpecialHero />}
      content={<CommonContent headline="Headline" body="Some text" />}
    />
  );
};

Back to the CMS component, here’s what we’ll do:

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

export const CmsLandingPage = ({ hero, content }: CmsLandingPageProps) => {
  const heroElement = <CmsComponent {...hero} />;
  const contentElement = content.map((item) => (
    <CmsComponent key={item.id} {...item} />
  ));

  return <LandingPage hero={heroElement} content={contentElement} />;
};

The CMS component is responsible for the "deserialization" of the other CMS components that are going to occupy its slots, and then it just passes them to the UI component as React elements.

We proceed in the same manner for all the other components (which I’m going to omit here for brevity), so that for each component we had previously, we’re now going to have two components, a CMS component and a UI component.

In this separation, we’re creating two layers: a CMS layer and a UI layer, where the CMS layer knows about the UI layer, but the UI layer remains ignorant of the CMS layer, which is what causes the decoupling.

Besides the fact that now we can reuse these UI components outside of a CMS context, there are additional benefits in terms of maintainability, namely:

It reduces the amount of information we have to deal with (AKA cognitive load) in each context, that is, when dealing with the CMS and the props that originate from it, we don’t have to think about the UI itself, whereas when dealing with the UI itself, we don’t need to think about the CMS.

Whenever we need to make changes to the CMS layer, for instance, due to changes in the components that are available for a certain slot, or due to CMS props being renamed, we can confidently do so without being afraid of breaking the UI, as we’re not even touching the UI itself to begin with.

Conversely, when making changes to the UI layer, there’s no risk of breaking the integration with the CMS, as the UI layer doesn’t even depend on the CMS layer.

This CMS layer is also a good place to "massage data", that is, to change the data representation that we get from the CMS to a more malleable representation before passing it to the UI.

In our current examples, the data we get from the CMS is formatted very nicely to make it easier to grasp, however, in reality, many times this is not the case. One of the benefits of using a Headless CMS is that it may have multiple different platforms consuming it (e.g. mobile, desktop, web, etc), which means that the data representation won’t necessarily be the best one for your specific application.

For example, the schema for CommonContent could be something like this, instead:

// ...

export type CommonContent = {
  id: string;
  component: "CommonContent";
  props: Array<{ type: string; key: string; value: string }>;
};

// ...

And here’s the data that would come from the CMS:

{
  "id": "347c0ac9-3cfe-4a72-8112-1f594967eaa7",
  "component": "CommonContent",
  "props": [
    {
      "type": "string",
      "key": "content_title",
      "value": "Our History"
    },
    {
      "type": "string",
      "key": "content_body",
      "value": "We were founded in 1999 and have been the best ever since."
    }
  ]
}

As you might imagine, this representation is not very convenient to deal with, however, we could isolate this complexity within the CMS component itself and keep the UI component using a better representation:

import { CommonContent as CmsCommonContentProps } from "@/infrastructure/cms/schemas";
import { CommonContent } from "../components/CommonContent";

export const CmsCommonContent = ({ props }: CmsCommonContentProps) => {
  const headline = props.find((prop) => prop.key === "content_title")!.value;
  const body = props.find((prop) => prop.key === "content_body")!.value;

  return <CommonContent headline={headline} body={body} />;
};

Now, consider what would happen if our UI component was coupled to this data representation, both in terms of the complexity of having to deal with UI and CMS concerns at the same time and also when trying to reuse this component outside of a CMS context.

Additionally, if you ever have to change the Headless CMS you use (which, by the way, has happened to me), you only need to change the CMS layer, and can keep the UI layer intact.

Code Splitting

Code splitting is a technique where we split the Javascript bundle we send to the client into multiple smaller chunks so that we can send only the code that’s actually needed for each page, while also being able to load it only when necessary (lazy loading).

We do that because we always want to send as little code as possible to the client in the first load so that the application becomes interactive as fast as possible.

When using a framework like NextJS or Remix, most stuff will be code split automatically as the bundler is already configured to take care of that by statically analyzing the dependency graph of your application (i.e. what imports what).

However, our current approach is somewhat incompatible with code splitting, as we’re currently sending the code for all the CMS components for all pages that are built from the CMS, even if a given page doesn’t use the majority of the existing components.

Initially, this might not have a huge impact on the initial load, but as the application evolves and CMS components increase in number and size, this will eventually become a problem.

For instance, suppose there’s an /about page that has the following data:

{
  "id": "58d93c62-1aed-4174-853e-839f859790fb",
  "component": "LandingPage",
  "hero": {
    "id": "f743fb69-8de5-46f4-9848-a359f58e7941",
    "component": "Hero",
    "headline": "The BEST company ever!",
    "body": "We are the best company ever. We do the best work and have the best people. We are the best!"
  },
  "content": [
    {
      "id": "347c0ac9-3cfe-4a72-8112-1f594967eaa7",
      "component": "CommonContent",
      "headline": "Our History",
      "body": "We were founded in 1999 and have been the best ever since."
    },
    {
      "id": "e0d5a9f7-6f3d-4c7d-9b8b-3b4e5a5d6f3d",
      "component": "CommonContent",
      "headline": "Our Mission",
      "body": "Our mission is to be the best ever."
    },
    {
      "id": "1e8f3d1d-3d3d-4e3e-8e3d-3d3d3d3d3d3d",
      "component": "CommonContent",
      "headline": "Our Vision",
      "body": "Our vision is to be the best ever."
    }
  ]
}

This page only uses 3 components: LandingPage, Hero, and CommonContent.

After building the app, this is what we get:

When building the app with NextJS, it generates a report of every page and how much JS it needs for its first load.

/[[...slug]] represents all pages that are built with CMS data, and its first load is 81.5kB.

Now, for testing purposes, let’s create a heavy.json file that has ~25MB of data and import it in the YoutubeVideo component:

import heavy from "./heavy.json";

export type YoutubeVideoProps = {
  videoId: string;
};

export const YoutubeVideo = ({ videoId }: YoutubeVideoProps) => {
  // We have to assign it to something
  // otherwise it will be tree-shaken
  const x = heavy;

  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>
  );
};

Then let’s build the application again:

Now, the first load for any CMS page has jumped to 3.88MB!

The YoutubeVideo component, however, is not used for all CMS pages, in particular, we know it’s not used for the /about page, yet it was still included in the initial bundle, otherwise, the initial load size wouldn’t have changed.

We’ll discuss how to solve this problem, but first, let me show you why the current approach is not suitable for code splitting. Take a look at the CmsComponent once more:

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 { CmsLandingPage } from "./CmsLandingPage";
import { CmsInfoPage } from "./CmsInfoPage";
import { CmsHero } from "./CmsHero";
import { CmsCommonContent } from "./CmsCommonContent";
import { CmsMediaContent } from "./CmsMediaContent";
import { CmsImage } from "./CmsImage";
import { CmsYoutubeVideo } from "./CmsYoutubeVideo";

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

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

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

const componentMatrix = {
  LandingPage: CmsLandingPage,
  InfoPage: CmsInfoPage,
  Hero: CmsHero,
  CommonContent: CmsCommonContent,
  MediaContent: CmsMediaContent,
  Image: CmsImage,
  YoutubeVideo: CmsYoutubeVideo,
} satisfies Record<CmsComponentProps["component"], unknown>;

This component is responsible for selecting the correct CMS component to be rendered according to the props it receives, and thus it imports all CMS components.

Because of that, we’re essentially telling the bundler that all of these components must be included in the bundle, regardless of whether they’re actually being rendered.

Unfortunately, there’s no easy way out of this problem using static analysis alone, for the very fact that most times we do not know which components we will render in compile time.

Whenever we have such a situation, where we don’t want to send all the code at once to the client, but also do not know in compile time what parts of the code we’ll need to send beforehand, we have to resort to lazy loading.

With lazy loading, we can defer sending code to the client until it asks us for that code during runtime.

React gives us an easy and idiomatic way to lazy load components, through the React.lazy function.

To use it, instead of importing a component as we usually do, we create a function that imports it dynamically and passes it to React.lazy, which then returns us a component that can be used pretty much in the same way as the non-decorated version of the component.

The only major difference is that we need to wrap it with a Suspense boundary, which is triggered while the code for that component is loaded.

So let’s do that for the CommonContent within CmsCommonContent:

import { CommonContent as CmsCommonContentProps } from "@/infrastructure/cms/schemas";
import { lazy } from "react";

const CommonContent = lazy(() =>
  import("../components/CommonContent").then((m) => ({
    default: m.CommonContent,
  })),
);

export const CmsCommonContent = ({ headline, body }: CmsCommonContentProps) => {
  return (
    <Suspense fallback={<>Loading...</>}>
      <CommonContent headline={headline} body={body} />;
    </Suspense>
  );
};

Now, the initial bundle we send to the client won’t include the code for CommonContent (supposing we don’t import it somewhere else), and only when CmsCommonContent is rendered for the first time, we’ll request the code for CommonContent, which will happen after the initial load.

While this code is being downloaded, we’ll show the component that’s passed to the Suspense’s fallback.

But now we have yet another problem — we’re defeating SSR.

Bear with me. As the code for whatever components we lazy load is not present in the initial bundle, it means that during hydration, the code for the component won’t be there, and thus we’ll hydrate the fallback instead of the component itself.

Given that during hydration, the very first render on the client has to match exactly whatever has been rendered on the server, even though code-splitting makes no difference for the server as we already have all the code loaded, we cannot pre-render the lazy loaded component, otherwise, it wouldn’t match the first render on the client, causing a hydration mismatch.

So, what we really need is a way to lazy load components in a way that’s SSR-friendly.

There are different ways of doing that depending on your setup, but as we’re using NextJS for our examples, I’ll show you how to do that within this framework.

And it’s deceptively simple, we just need to exchange React.lazy for NextJS’s dynamic, like so:

import { CommonContent as CmsCommonContentProps } from "@/infrastructure/cms/schemas";
import dynamic from "next/dynamic";

const CommonContent = dynamic(() =>
  import("../components/CommonContent").then((m) => m.CommonContent),
);

export const CmsCommonContent = ({ headline, body }: CmsCommonContentProps) => {
  // When using `dynamic` it is not mandatory
  // to use a Suspense boundary

  return (
      <CommonContent headline={headline} body={body} />;
  );
};

I won’t get into the details of how dynamic works, but what matters most for us is the fact that it is SSR-friendly. So even though we’re lazy loading components, if we "reach" them during SSR, instead of rendering a fallback, we’ll render the components themselves, and then we’ll pass the information of which bundles we need to wait for before starting hydration, and thus on the first render on the client we’ll already have the code for these components as well.

Okay, so now that we’re able to do code splitting for the CMS components, we repeat this process of importing components dynamically for every CMS component, so that we’ll always only send the code for the components that we need.

Also, because of the nature of CMSes, where content is essentially static (i.e. it doesn’t change after you load the page), it’s very unlikely, if not impossible, to run into a situation where you’ll dynamically load a component after the first load.

After converting all CMS components to use this approach, let’s build the app once again and see what we get.

Even though we’re still importing that heavy JSON file inside YoutubeVideo, the first load remains unchanged as UI components will only be included in the initial load as needed.

This approach works very well to keep the initial load as fast as possible, but there’s one caveat that we’ll be talking about next.

The Perils of HTTP 1

For most bundlers (including the bundler that NextJS uses), whenever you import some code dynamically, the bundler will turn that code into a separate bundle.

In our case, this means that for each UI component, a separate bundle will be generated.

Now, if you’re a little bit older experienced in web development, you probably remember why bundlers exist in the first place, which is not to transpile, or minify code, but essentially to bundle different files in a single one.

The reason why bundling was first developed, is because under HTTP 1 the browser can only do a limited (about 6) number of simultaneous HTTP requests.

This means that, if you’re requesting a large number of JS files to the server, some of these requests will not even start for a while.

In practice, even though we’re hoping to speed up the initial load by sending less code, we could end up slowing it down (which has happened to me) by having to download a lot of small bundles that will need to wait for each other, given we have a limited number of requests we can make at once.

This problem is easily solved by using HTTP 2 instead, as it supports multiplexing, and thus has no such limitations in terms of simultaneous requests.

Thankfully, all major cloud providers support HTTP 2, but unfortunately, not all of them do, so consider that before code splitting everything.

Conclusion

In this post, we talked about decoupling the CMS from the UI and how to keep our approach to working with a Headless CMS compatible with code-splitting.

In the last post of the series, we’ll tackle the situation where some CMS components need to receive props from other components, which is incredibly common and tricky to deal with.

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