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
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)
Get in there and fire up this bad boy:
cd cosmic-demo
bun dev
You should now see the default template running at http://localhost:3000. It should look like this:
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:
Make sure to select the React option:
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.
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
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));
} */
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:
-
app/page.tsx
- RSC -
components/Header.tsx
- RSC -
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.
Add the Cosmic JavaScript SDK with:
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).
// 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).
// 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">→</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:
// 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.
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">→</span>
</a>
</div>
</nav>
</header>
</div>
);
}
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>
</>
);
}
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:
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>
);
}
Then add a new nav item in the Cosmic Object so you can get to it.
Click on the new nav link and your page is now there!
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 asgetServerSideProps
,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, sign up here.
- To learn more about React Server Components, I recommend this extensive article by Josh W. Comeau.
Top comments (0)