Best Practices using React Server Components with a Headless CMS
Tags: react, nextjs, headlesscms
Published: 2023-12-05 15:33:48

---
title: Best Practices using React Server Components with a Headless CMS
published: true
description: React Server Components offer quite a significant improvement to developer experience compared to pre-RSC Next.js especially when working with a headless CMS.
tags: React, Nextjs, HeadlessCMS
cover_image: https://imgix.cosmicjs.com/d0b33850-92bb-11ee-a141-bf415eaf0a74-rsc-headless-cms.png?w=1800&auto=format&dpr=2
canonical_url: https://www.cosmicjs.com/blog/best-practices-using-react-server-components-with-a-headless-cms
# Use a ratio of 100:42 for best results.
# published_at: 2023-12-05 15:19 +0000
---

Since [React Server Components](https://vercel.com/blog/understanding-react-server-components) (RSCs) hit the scene, the concept of server-side rendering React has become a [polarizing topic](https://www.reddit.com/r/reactjs/comments/14rncns/my_take_on_the_current_react_server_components/), garnering [praise](https://www.reddit.com/r/reactjs/comments/16p8dd6/react_server_components_made_our_site_a_lot_faster/), [skepticism](https://www.youtube.com/watch?v=dH6_cJwgHnM&ab_channel=AaronFrancis) as well as becoming the target of [many](https://vived.io/wp-content/uploads/2022/10/6xl7is.jpeg) [developer](https://twitter.com/chancethedev/status/1341116006572290049) [jokes](https://i.ytimg.com/vi/hr_y1hIdZHs/sddefault.jpg).

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](https://www.cosmicjs.com/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](https://www.cosmicjs.com)). Alright, let’s get into it:

## 1. Install the template

First install a new Next.js 14 app (you will need [bun](https://www.npmjs.com/package/bun) installed)

```bash
bunx create-next-app cosmic-demo
```

Yes / No to the following:
```bash
✔ 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)
```

Get in there and fire up this bad boy:

```bash
cd cosmic-demo
bun dev
```

You should now see the default template running at http://localhost:3000. It should look like this:![nextjs-template.png](https://imgix.cosmicjs.com/2c0c8b50-907f-11ee-a141-bf415eaf0a74-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](https://tailwindui.com/components/marketing/sections/heroes):
![tailwind-ui-hero.png](https://imgix.cosmicjs.com/9362b1f0-90a0-11ee-a141-bf415eaf0a74-tailwind-ui-hero.png)

Make sure to select the React option:
![tailwind-css-page.png](https://imgix.cosmicjs.com/35983d40-907f-11ee-a141-bf415eaf0a74-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](https://imgix.cosmicjs.com/4958d970-907f-11ee-a141-bf415eaf0a74-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:

```bash
bun add @headlessui/react @heroicons/react
```

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

```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));
} */
```

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

<video width="100%" height="350" autoplay playsinline muted loop>
    <source src="https://cdn.cosmicjs.com/59c03880-907f-11ee-a141-bf415eaf0a74-nav-demo.mp4" type="video/mp4">
  </video>

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](https://app.cosmicjs.com/login), 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](https://imgix.cosmicjs.com/89506de0-907f-11ee-a141-bf415eaf0a74-cosmic-home.png)

Add the [Cosmic JavaScript SDK](https://www.npmjs.com/package/@cosmicjs/sdk) with:

```bash
bun add @cosmicjs/sdk
```

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).

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

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

```jsx
// 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>
  );
}
```

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:

```tsx
// 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>
  );
}
```

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](https://imgix.cosmicjs.com/98c88af0-907f-11ee-a141-bf415eaf0a74-cosmic-header.png)

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

```tsx
// 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>
  );
}
```

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

```tsx
// 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>
    </>
  );
}
```
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](https://imgix.cosmicjs.com/a3f47f60-907f-11ee-a141-bf415eaf0a74-cosmic-about.png)

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

```tsx
// 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>
  );
}
```

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

![cosmic-header-updated.png](https://imgix.cosmicjs.com/aeb92680-907f-11ee-a141-bf415eaf0a74-cosmic-header-updated.png)

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

![about-page.png](https://imgix.cosmicjs.com/b6ec4d50-907f-11ee-a141-bf415eaf0a74-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

* To learn how to manage content for your websites and apps with Cosmic, which makes a great [Next.js CMS](https://www.cosmicjs.com/knowledge-base/nextjs-cms), [sign up here](https://app.cosmicjs.com/signup).
* To learn more about React Server Components, I recommend [this extensive article](https://www.joshwcomeau.com/react/server-components/) by Josh W. Comeau.