DEV Community

Cover image for Best Practices using React Server Components with a Headless CMS
Tony Spiro
Tony Spiro

Posted on • Originally published at cosmicjs.com

Best Practices using React Server Components with a Headless CMS

Since React Server Components (RSCs) hit the scene, the concept of server-side rendering React has become a polarizing topic, garnering praise, skepticism as well as becoming the target of many developer jokes.

After exploring RSCs with Next.js (available since Next.js 13), I have to say that it offers quite a significant improvement to developer experience compared to pre-RSC Next.js especially when working with a headless CMS.

In this article, we’re going to cover best practices for using React Server Components along with a headless CMS. We’ll also cover when to use client-side components and how to best structure your websites and apps for efficient data fetching from a headless CMS, in our case Cosmic (learn more about Cosmic here). Alright, let’s get into it:

1. Install the template

First install a new Next.js 14 app (you will need bun installed)

bunx create-next-app cosmic-demo
Enter fullscreen mode Exit fullscreen mode

Yes / No to the following:

✔ Would you like to use TypeScript? … No / Yes (Select Yes)
✔ Would you like to use ESLint? … No / Yes (Select Yes)
✔ Would you like to use Tailwind CSS? … No / Yes (Select Yes)
✔ Would you like to use `src/` directory? … No / Yes (Select No)
✔ Would you like to use App Router? (recommended) … No / Yes (Select Yes)
✔ Would you like to customize the default import alias (@/*)? … No / Yes (Select No)
Enter fullscreen mode Exit fullscreen mode

Get in there and fire up this bad boy:

cd cosmic-demo
bun dev
Enter fullscreen mode Exit fullscreen mode

You should now see the default template running at http://localhost:3000. It should look like this:nextjs-template.png

2. Copy + paste the Tailwind UI page example

To get up and running quickly, we’ll grab a Tailwind CSS UI demo page located here:
tailwind-ui-hero.png

Make sure to select the React option:
tailwind-css-page.png

Paste it into app/page.tsx. After saving, you’ll notice it throws an error because you cannot use lifecycle events such as useEffect() or state in a React Server Component.
fail-to-compile.png

So we'll need to remove the client-side code from this page and move it elsewhere. But before we do that, let's install the missing dependencies:

bun add @headlessui/react @heroicons/react
Enter fullscreen mode Exit fullscreen mode

And let’s comment out the dark style in globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

/* :root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  :root {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

body {
  color: rgb(var(--foreground-rgb));
  background: linear-gradient(
      to bottom,
      transparent,
      rgb(var(--background-end-rgb))
    )
    rgb(var(--background-start-rgb));
} */
Enter fullscreen mode Exit fullscreen mode

3. Separate the components

Since we can't use any client-side functionality in RSCs (in our case the onClick event for opening the mobile drawer and useState hook which handles client-side state), we'll need to separate this functionality into client components.

Create a new folder titled components and add a file titled Header.tsx and put everything from the <header> tags into it along with the dependencies and state functions.

In Header.tsx add “use client” to the top of the file. You will see now we have client-side interactions in the nav on mobile:

But there's a problem: we don’t want to render the FULL navigation client-side because that would negatively impact SEO. Since only the mobile functionality needs to render client-side, we can put the mobile drawer into its own component.

Create a new file at components/MobileNav.tsx and move the mobile nav client code which includes the nav button and the <Dialog> component and any dependencies. We'll come back to this after we power up our React Server Components with dynamic content from the Cosmic CMS.

This gives us three component files:

  1. app/page.tsx - RSC
  2. components/Header.tsx - RSC
  3. components/MobileNav.tsx - Client component

4. Add Cosmic power

Now that we have a separation between our server and client code, let’s add the Cosmic magic. In the Cosmic dashboard, create a new Project and new Object type titled Page with the following Metafields:

  • Headline - Text
  • Subheadline - Text
  • Hero - Image

Create a new Page in Cosmic titled Home and add any content you want.
cosmic-home.png

Add the Cosmic JavaScript SDK with:

bun add @cosmicjs/sdk
Enter fullscreen mode Exit fullscreen mode

Next add the a new file lib/cosmic.ts with the following (Go to Bucket > API keys to get your Bucket slug and read key).

// lib/cosmic.ts
import { createBucketClient } from "@cosmicjs/sdk";
export const cosmic = createBucketClient({
  bucketSlug: "YOUR_BUCKET_SLUG",
  readKey: "YOUR_BUCKET_READ_KEY",
});
Enter fullscreen mode Exit fullscreen mode

Then in your app/page.tsx file add the following (note where we add the Cosmic-powered sections).

// app/page.tsx

import { cosmic } from "@/lib/cosmic";

export default async function Home() {
  // fetch Cosmic data
  const { object: page } = await cosmic.objects
    .findOne({
      type: "pages",
      slug: "home",
    })
    .props("metadata")
    .depth(1);

  return (
    <main>
      <div className="relative isolate px-6 pt-14 lg:px-8">
        <div
          className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80"
          aria-hidden="true"
        >
          <div
            className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
            style={{
              clipPath:
                "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
            }}
          />
        </div>
        <div className="mx-auto max-w-2xl sm:py-48">
          <div className="hidden sm:mb-8 sm:flex sm:justify-center">
            <div className="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20">
              Announcing our next round of funding.{" "}
              <a href="#" className="font-semibold text-indigo-600">
                <span className="absolute inset-0" aria-hidden="true" />
                Read more <span aria-hidden="true">&rarr;</span>
              </a>
            </div>
          </div>
          <div className="text-center">
            <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
              {page.metadata.headline}
            </h1>
            <p className="mt-6 text-lg leading-8 text-gray-600">
              {page.metadata.subheadline}
            </p>
            <div className="mt-10 flex items-center justify-center gap-x-6">
              <a
                href="#"
                className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
              >
                Get started
              </a>
              <a
                href="#"
                className="text-sm font-semibold leading-6 text-gray-900"
              >
                Learn more <span aria-hidden="true"></span>
              </a>
            </div>
          </div>
        </div>
        <img src={page.metadata.hero.imgix_url} className="w-full" />
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note here that we are exporting an async function which is unique to React Server Components, enabling us to use await cosmic.objects.findOne() to fetch our page from Cosmic!

The big takeaway: No need messing with getStaticProps, getServerSideProps, or getStaticPaths it Just Works™.

5. Adding the global header

You may now notice an issue: we lost our navigation. So let's add our global header back. Because we’ll want to have the header navigation show up globally on all pages, let’s add it to our app/layout.tsx file:

// app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/Header";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Header />
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next let’s add dynamic content to our nav elements. In the Cosmic dashboard, create a new Object type titled Nav with the following Metafields:

  • Items - Repeater
    • Text - Text
    • Link - Text
    • New Tab - Switch (boolean)

Next create a new Object titled Header and add some content.

cosmic-header.png

Then add the following to your components/Header.tsx file:

// components/Header.tsx

import Link from "next/link";
import { cosmic } from "@/lib/cosmic";
import { MobileNav } from "@/components/MobileNav";

export type ItemType = {
  text: string;
  link: string;
  new_tab: boolean;
};

export async function Header() {
  // fetch Cosmic data
  const { object: nav } = await cosmic.objects
    .findOne({
      type: "navs",
      slug: "header",
    })
    .props("metadata")
    .depth(1);

  return (
    <div className="bg-white">
      <header className="absolute inset-x-0 top-0 z-50">
        <nav
          className="flex items-center justify-between p-6 lg:px-8"
          aria-label="Global"
        >
          <div className="flex lg:flex-1">
            <Link href="/" className="-m-1.5 p-1.5">
              <span className="sr-only">Your Company</span>
              <img
                className="h-8 w-auto"
                src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600"
                alt=""
              />
            </Link>
          </div>
          <MobileNav items={nav.metadata.items} />
          <div className="hidden lg:flex lg:gap-x-12">
            {nav.metadata.items.map((item: ItemType) => (
              <Link
                key={item.text}
                href={item.link}
                className="text-sm font-semibold leading-6 text-gray-900"
                target={item.new_tab ? "_blank" : ""}
              >
                {item.text}
              </Link>
            ))}
          </div>
          <div className="hidden lg:flex lg:flex-1 lg:justify-end">
            <a
              href="#"
              className="text-sm font-semibold leading-6 text-gray-900"
            >
              Log in <span aria-hidden="true">&rarr;</span>
            </a>
          </div>
        </nav>
      </header>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next add the following to your components/MobileNav.tsx file:

// components/MobileNav.tsx

"use client";

import Link from "next/link";
import { useState } from "react";
import { Dialog } from "@headlessui/react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
import { ItemType } from "@/components/Header";

export function MobileNav({ items }: { items: Item[] }) {
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
  return (
    <>
      <div className="flex lg:hidden">
        <button
          type="button"
          className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700"
          onClick={() => setMobileMenuOpen(true)}
        >
          <span className="sr-only">Open main menu</span>
          <Bars3Icon className="h-6 w-6" aria-hidden="true" />
        </button>
      </div>
      <Dialog
        as="div"
        className="lg:hidden"
        open={mobileMenuOpen}
        onClose={setMobileMenuOpen}
      >
        <div className="fixed inset-0 z-50" />
        <Dialog.Panel className="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10">
          <div className="flex items-center justify-between">
            <Link href="#" className="-m-1.5 p-1.5">
              <span className="sr-only">Your Company</span>
              <img
                className="h-8 w-auto"
                src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600"
                alt=""
              />
            </Link>
            <button
              type="button"
              className="-m-2.5 rounded-md p-2.5 text-gray-700"
              onClick={() => setMobileMenuOpen(false)}
            >
              <span className="sr-only">Close menu</span>
              <XMarkIcon className="h-6 w-6" aria-hidden="true" />
            </button>
          </div>
          <div className="mt-6 flow-root">
            <div className="-my-6 divide-y divide-gray-500/10">
              <div className="space-y-2 py-6">
                {items.map((item: ItemType) => (
                  <Link
                    key={item.text}
                    href={item.link}
                    className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
                    target={item.new_tab ? "_blank" : ""}
                  >
                    {item.text}
                  </Link>
                ))}
              </div>
              <div className="py-6">
                <Link
                  href="#"
                  className="-mx-3 block rounded-lg px-3 py-2.5 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
                >
                  Log in
                </Link>
              </div>
            </div>
          </div>
        </Dialog.Panel>
      </Dialog>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is now our only client-side component and receives the Nav items from Cosmic via props.

6. More pages

Now what if you want to create another page? Easy, go into Cosmic and create a new page titled About with whatever content you want:

cosmic-about.png

Then create a new file located in app/about/page.tsx (notice the folder name is the page slug).

// app/about/page.tsx

import { cosmic } from "@/lib/cosmic";

export default async function Home() {
  const { object: page } = await cosmic.objects
    .findOne({
      type: "pages",
      slug: "about",
    })
    .props("metadata")
    .depth(1);
  return (
    <main>
      <div className="relative isolate px-6 pt-14 lg:px-8">
        <div
          className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80"
          aria-hidden="true"
        >
          <div
            className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
            style={{
              clipPath:
                "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
            }}
          />
        </div>
        <div className="mx-auto max-w-2xl sm:py-48">
          <div className="text-center">
            <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
              {page.metadata.headline}
            </h1>
            <p className="mt-6 text-lg leading-8 text-gray-600">
              {page.metadata.subheadline}
            </p>
          </div>
        </div>
        <img src={page.metadata.hero.imgix_url} className="w-full" />
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then add a new nav item in the Cosmic Object so you can get to it.

cosmic-header-updated.png

Click on the new nav link and your page is now there!

about-page.png

Notice that our global header is available on all pages because it was added to our layout.tsx file. And because Header is a React Server Component, it can fetch the CMS data directly, without having to do so on the page level which is also quite nice!

Another thing to note is that since the Header component is rendered in the layout and the navigation links use the Next.js <Link> element, it doesn’t refetch the data when you navigate between pages.

Conclusion

I hope you enjoyed this exploration of using React Server Components with a headless CMS. We covered:

  • When to use React Server Components and when to use client components.
  • We noted some of the benefits this new component structure offers when fetching data from a headless CMS. Specifically, we indicated that we can simply fetch data with an async function without having to use any pre-RSC methods in Next.js such as getServerSideProps, getStaticPaths, etc.
  • We also noted how RSCs make it easy to reuse data-powered components since the components themselves can fetch data server-side (for example our Header component).

React Server Components with Next.js makes for a simplified and improved developer experience when using a headless CMS.

Links

Top comments (0)