React Server Components Example with Next.js

  • April 16, 2024
  • Royce Threadgill
  • 19 min read

React Server Components (oftentimes shortened to RSC) provide us with a fascinating new approach to building React applications.

That said, my experience while researching this article was far less rosy and far more expletive laden than I expected.

But now that I have been one acquainted with the RSC, I’d like to share my findings with you so that hopefully your learning journey won’t be quite as painful or confusing.

In this article, we’ll discuss a range of topics, including:

  • What are React Server Components?
  • Can you use React Server Components without Next.js?
  • How do React Server Components work in Next.js?
  • What are the advantages and disadvantages of React Server Components?
  • Are React Server Components production ready?

We’ll also go through a React Server Components example using the Next.js app router. That example will discuss RSC streaming behavior and client and server component nesting. If you’re only interested in the code example, just scroll to the bottom of the article.

Let’s begin!

What are React Server Components?

My favorite definition of RSC comes from Mark Dalgleish in his 2023 React Advanced conference talk, “Simplifying Server Components”. The way he puts it is that “server components are just virtual DOM over the network”.

Mark Dalgleish provides RSC definition

What does this mean?

Let’s say we have a React component called Basic:

const Basic = () => {
  return (
    <main>
      <p>Hello, World</p>
    </main>
  );
};

On the server, that component would be serialized into what’s known as an RSC payload. That would look something like this in the Next.js server component implementation.

// ...complicated Next.js stuff up here
{
  children: [
    "__PAGE__",
    {},
    [
      "$L1",
      [
        "$",
        "main",
        null,
        {
          children: ["$", "p", null, { children: "Hello, World" }],
        },
      ],
      null,
    ],
  ];
}
// ...more complicated Next.js stuff down here

That RSC payload would then be streamed down to the client and rendered exactly like any other JSX.

ℹ️ Sidenote: This explanation gets at the core of React Server Components, but is a little simplified. If you want to learn more about what’s happening under the hood, I highly recommend watching to Daishi Kato’s “Exploring React Server Component Fundamentals” talk at React Day Berlin 2023.

Seems pretty simple, right? So why bother with this RSC payload nonsense? Why not just render the component to HTML and stream that down to the client?

Well, we can! Typically, we would call this server side rendering (SSR).

React Server Components vs SSR

We’ve actually been able to serialize React components to HTML for several years now.

You can find a solid definition of SSR in the Vite documentation:

SSR specifically refers to front-end frameworks (for example React, Preact, Vue, and Svelte) that support running the same application in Node.js, pre-rendering it to HTML, and finally hydrating it on the client.

Put simply, SSR pre-renders a React component to HTML, while RSC serializes a React component into an RSC payload.

Server components have a couple advantages over traditional SSR. For one, SSR requires a hydration step once the HTML has been sent to the client; the initially loaded page isn’t interactive until the entire JS bundle is downloaded and the entire page is hydrated.

By comparison, server components require no hydration step, allowing pages to load faster and become interactive more quickly.

The page loading issues raised by SSR can be mitigated by chunking your JavaScript bundle and streaming it to the client, but eventually the entire bundle needs to be downloaded. And that bundle tends to get larger as your application grows.

Server components, meanwhile, add no additional JavaScript to your bundle. As your application grows, your JavaScript bundle size does not grow with it (assuming you only use server components).

ℹ️ Sidenote: It’s important to note that RSC and SSR aren’t mutually exclusive. You can learn a bit more about this by reading “Making Sense of React Server Components” by Josh Comeau.

Server Components vs. Client Components

The Basic component above was a simple example. What happens when we add interactivity to our component?

Let’s say we add a Counter to our Basic component:

const Basic = () => {
  return (
    <main>
      <Counter />
      <p>Hello, World</p>
    </main>
  );
};

When we interact with the Counter, we would expect the value to increment. In other words, that component has state and must be declared as a client component in our React code.

How do we represent such a component in a format that can be generated on the server and sent to the client? The answer, as you may have guessed, lies in the RSC payload.

When a bundler supporting React Server Components detects a client component, it adds a reference to that component in the RSC payload – react.client.reference.

Mark Dalgleish explains RSC client references

The bundler also generates a manifest that maps the reference ID to the actual client component, which determines what JavaScript must be sent to the client (meaning that, unlike server components, client components will contribute to your bundle size).

In Episode #311 of the Changelog podcast, Dan Abramov said (and I’m paraphrasing a bit here) the RSC payload resulting from such scenarios can be thought of as having a script tag in place of the client component.

ℹ️ Sidenote: If you’re interested in learning a bit more about how client components are serialized, take a look at “React Server Components, without a framework?” by Tim Pillard.

As the late Billy Mays would say, “But wait, there’s more!”

What happens when you nest a server component inside that client component? As wacky as it sounds, your bundler recognizes that as a server component and serializes it as such in the RSC payload.

Don’t worry, we’ll get more into server components vs client components in our code example. But first, let’s address the elephant in the room….

React Server Components Without Next.js…?

Daishi Kato, creator of Zustand and Jotai, says there are a few things we need to support RSC serialization:

  • Bundler capable of recognizing client components, generating a manifest, and resolving client references on the client
  • Server capable of accepting HTTP requests and responding with (dynamic) RSC payloads
  • Router capable of intelligently serving resources (e.g., don’t render client components that are not useful for a given route)
  • SSR that works in tandem with React Server Components, largely for initial page load performance

So while the RSC concept is fairly straightforward in theory, there are a number of moving parts necessary for implementing RSCs in practice.

Currently, Next.js is the only production framework that fully supports RSCs.

This isn’t an accident; when Meta introduced React Server Components, Dan Abramov explicitly stated that they collaborated with the Next.js team to develop the RSC webpack plugin.

That means you’re mostly limited to Next.js if you’d like to build an app with RSCs.

📣 Important: This article was originally written in March 2024. At the very end of that month, RedwoodJS announced full support for server components! While there still appear to be some implementation issues, RedwoodJS does now offer a viable alternative for implementing React Server Components in production.

But What If I Really Don’t Like Next.js?

If you’re really against using Next.js, your options are limited and largely experimental.

The moderators at Reactiflux pointed me toward Waku (also developed by Daishi Kato) as a potential alternative. However, Daishi explicitly recommends using the framework on non-production projects.

At Node Conference 2023, Jarred Sumner (creator of Bun) showed a demo of server components in Bun, so there is at least partial support in that ecosystem. The Bun repo provides bun-plugin-server-components as the official plugin for server components. And while I haven’t looked at it in-depth, Marz claims to be a “React Server Components Framework for Bun”.

Jarred Sumner explains server components with Bun

In the Changelog Podcast episode referenced above, Dan Abramov alluded to Parcel working on RSC support as well. I couldn’t find much to back up that claim aside from a GitHub issue discussing directives and a social media post by Devon Govett (creator of Parcel), so I can’t say for sure if Parcel is currently a viable option for developing with RSCs.

And you can’t use React Server Components with Vite right now, but support is being worked on.

React Server Components Example with Next.js

Alright, with that out of the way, let’s get to the code example. Note that we’re using Tailwind for styling just to make the example a bit less ugly; Tailwind has no other bearing on the example.

For reference, here are our dependencies:

  "dependencies": {
    "react": "^18",
    "react-dom": "^18",
    "next": "14.1.3"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "eslint": "^8",
    "eslint-config-next": "14.1.3"
  }

We’re going to cover a few concepts here, but we’ll start with a React Server Components Suspense example to demonstrate how payload streaming is handled.

React Server Components and Suspense

React Suspense can intelligently display parts of the RSC payload as they are streamed down to the client. If the component is not yet available, Suspense will fall back to a loading state.

Let’s say we have a component Multiple, which has multiple server components that take varying amounts of time to load:

// src/app/multiple/page.tsx

const Multiple = () => {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="flex w-1/2">
        <div className="flex flex-col gap-8">
          <h1 className="text-4xl">Multiple long loading things</h1>

          <Suspense fallback={<p>Loading...</p>}>
            <LongLoadingComponent />
          </Suspense>

          <Suspense fallback={<p>Loading...</p>}>
            <LongerLoadingComponent />
          </Suspense>

          <Suspense fallback={<p>Loading...</p>}>
            <LongestLoadingComponent />
          </Suspense>
        </div>
      </div>
    </main>
  );
};

export default Multiple;

Each server component is wrapped within a Suspense component, which falls back to “Loading…” text until the server component is available.

For the purposes of this example, we’ll mock each long running query with a Promise that resolves after a defined timeout. So each of the server components will look something like this:

const longLoadingFetch = async () => {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Vegeta, what does the scouter say about his power level?");
    }, 2000);
  });

  return promise;
};

const LongLoadingComponent = async () => {
  const res = await longLoadingFetch();

  return (
    <div className="text-white border border-white p-4">{res as string}</div>
  );
};

export default LongLoadingComponent;

Each component will have a different timeout value and string to simulate different server components with different query durations.

Initially, each server component is unavailable and all display the “Loading…” text. Then the quickest component renders, then the second quickest, then the third quickest.

React suspense example

In Episode #718 of the Syntax podcast, Wes Bos pointed out a handy RSC Devtools Chrome extension that we can use to better understand what’s going on here.

RSC Devtools tree

We can see that we have three lazily loaded components, which lines up with what we would expect. If we click on the first node, we see the RSC payload and the rendered content.

RSC Devtools component

Also note that the page header has already been rendered! The remainder of the RSC payload is streamed down to the client as it becomes available. Importantly, this means the long-running queries are not blocking page rendering.

ℹ️ Sidenote: If you’d like to learn more about how the RSC payload is streamed to the client, check out “Why React Server Components Are Breaking Builds to Win Tomorrow”.

But you may have noticed that this example only deals with server components. Let’s examine a more complex example that involves both client and server components.

Interleaving Client and Server Components

This is where the fun begins. In this example, we’ll be looking at how to implement some basic search functionality. Let’s take a look at the Search component:

// src/app/search/page.tsx

const Search = ({
  searchParams,
}: {
  searchParams?: { [key: string]: string | string[] | undefined };
}) => {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="flex w-1/2">
        <div className="flex flex-col gap-8">
          <h1 className="text-4xl">Search example</h1>

          {/* client component */}
          <CounterProvider>

            {/* client component */}
            <SearcheableToDoList>

              {/* client component */}
              <SearchField />

              <Suspense fallback={<p>Loading...</p>}>

                {/* server component */}
                <ToDoList searchParams={searchParams}>

                  {/* client component */}
                  <CounterDisplay />
                </ToDoList>
              </Suspense>
            </SearcheableToDoList>
          </CounterProvider>
        </div>
      </div>
    </main>
  );
};

export default Search;

I’ve added comments to indicate where there are server and client components. Note that the searchParams value passed into the Search component is a Next-specific property containing the query parameters for the page.

Let’s briefly discuss what the individual components are doing:

  • CounterProvider is a client component providing a context to its children components; the context value is just counterVal and setCounterVal.
  • SearcheableToDoList is a client component wrapper that uses the above context to increment the counter value on clicking a button; it displays the counterVal, a button to increment the counter, and its children.
  • SearchField is a client component input that updates the URL query parameters
  • ToDoList is a server component that reads data from a data.json file, then filters that data based on the searchParams retrieved in the Search parent; it displays the resulting data and its children.
  • CounterDisplay is a client component that displays the counter value from the CounterProviderContext.

The Next.js documentation refers to this nested pattern as “interleaving” server and client components.

But how is it that we can nest a server component within our client components?

Recall that React Server Components are just serialized vDOM sent over the network. So as long as the content in the React tree is serializable, it can be handled without issue by your bundler.

In this case, the ToDoList server component is serializable specifically because it is a child of its client component wrapper. Children of a React component are really just props – and props are serializable!

Here’s what the rendered components look like:

Interleaved components example

There are a couple other little tidbits we can glean from the interleaved components.

You Can Still Use Contexts (Carefully)

In Next.js v13+, all components are server components by default and cannot use state hooks like useState, useEffect, or useContext.

Client components operate exactly the same as before and can use state hooks. For example:

"use client";

type CounterProviderValue = {
  counterVal: number;
  setCounterVal: Dispatch<SetStateAction>;
};

export const CounterProviderContext: React.Context =
  createContext({
    counterVal: 0,
    setCounterVal: () => {},
  });

export const CounterProvider: React.FC<PropsWithChildren> = ({ children }) => {
  const [counterVal, setCounterVal] = useState(0);

  return (
    <CounterProviderContext.Provider value={{ counterVal, setCounterVal }}>
      {children}
    </CounterProviderContext.Provider>
  );
};

We declare CounterProvider as a client component with the use client directive at the top of the file. This directive is how the bundler identifies CounterProvider as a client component and treats it accordingly in the RSC payload.

We can use useState in CounterProvider only because we’ve declared it as a client component.

Similarly, we can access the counterVal and setCounterVal in child components, but only if those children are client components.

"use client";

const SearcheableToDoList: React.FC<PropsWithChildren> = ({ children }) => {
  const { counterVal, setCounterVal } = useContext(CounterProviderContext);

  return (
    <div className="flex flex-col gap-2">
      <div className="flex gap-2">
        <button
          className="bg-white text-black"
          onClick={() => setCounterVal(counterVal + 1)}
        >
          Counter
        </button>
        <CounterDisplay />
      </div>
      <div className="flex flex-col gap-2">{children}</div>
    </div>
  );
};

export default SearcheableToDoList;

Because SearcheableToDoList is a client component, we can access counterVal and setCounterVal via useContext.

The same can be said of CounterDisplay. As long as it is declared as a client component, we can use counterVal and setCounterVal. That’s true even when CounterDisplay is a child of the ToDoList server component.

"use client";

const CounterDisplay = () => {
  const { counterVal } = useContext(CounterProviderContext);

  return <span>Count is {counterVal}</span>;
};

export default CounterDisplay;

You Can Use Server Code in Server Components

Server components are rendered on the server, so we can use any server side code we’d like! For instance, we can read from the file system in ToDoList to return the list of filtered to-dos.

import { promises as fs } from "fs"; // import included for clarity

type ToDoListProps = PropsWithChildren & {
  searchParams?: { [key: string]: string | string[] | undefined };
};

type ToDo = {
  id: number;
  heading: string;
  body: string;
  isFavorite: boolean;
};

const fetchData = async (searchParams: {
  [key: string]: string | string[] | undefined;
}) => {
  const file = await fs.readFile(`${process.cwd()}/src/app/data.json`, "utf8");
  const parsedJson = JSON.parse(file);
  const { data } = parsedJson;

  const headingSearch = searchParams["heading"] as string;

  const res = (data as ToDo[]).filter((item) =>
    item.heading.includes(headingSearch)
  );

  return res;
};

const ToDoList: React.FC<ToDoListProps> = async ({
  searchParams,
  children,
}) => {
  const data = await fetchData(
    searchParams as { [key: string]: string | string[] }
  );

  return (
    <div className="flex gap-4">
      <div className="flex flex-col gap-4">
        {data.map((item: ToDo) => (
          <div key={item.id} className="border border-white p-4">
            <h2 className="text-2xl">{item.heading}</h2>
            <p>{item.body}</p>
          </div>
        ))}
      </div>
      <div>{children}</div>
    </div>
  );
};

export default ToDoList;

When SearchField updates the query parameters, the searchParams passed to Search are updated and passed to ToDoList. We then search the file system using fs.readFile and filter out the results based on the provided query parameters.

ℹ️ Sidenote: This works for our example, but there’s a bit of nuance here with Next.js caching behavior that may cause issues if you try something similar in production. Check out this GitHub discussion and the Next.js docs for more information.

After we’ve fetched our data and populated the component, the resulting RSC payload is streamed down to the client. Notably, this means that JavaScript used within your server component (including imports) does not get added to the bundle on your front-end!

For completeness, here’s the SearchField component code:

const SearchField = () => {
  const router = useRouter();
  const pathname = usePathname();

  const [searchText, setSearchText] = useState("");

  const searchTextTimeout: ReturnType<typeof setTimeout> = setTimeout(() => null, 500);
  const searchTextTimeoutRef = useRef<ReturnType<typeof setTimeout>>(searchTextTimeout);

  useEffect(() => {
    clearTimeout(searchTextTimeoutRef.current);

    // this is just a debounce so we don't update query params on every keystroke
    searchTextTimeoutRef.current = setTimeout(() => {
      router.push(
        !!searchText?.length ? `${pathname}/?heading=${searchText}` : pathname
      );
    }, 500);
  }, [searchText, searchTextTimeoutRef, router, pathname]);

  return (
    <div>
      <input
        className="text-black"
        onChange={(e) => setSearchText(e.target.value)}
      />
    </div>
  );
};

What Are the Advantages of React Server Components?

Using server components means your JavaScript bundle size will not scale with the size of your codebase. Couple this with the fact that server components don’t require hydration and you get a shortened time-to-interactive and a reduction in page load times.

That reduced bundle size means client machines don’t need to download as much JavaScript. Additionally, executing JavaScript on the server is often more efficient for the end-user than executing it on the client (especially for mobile devices). Both of these points lead to improved user experiences for Next.js v13 and up.

Another interesting point is that executing fetches on the server can allow developers to more easily leverage caching. Next.js already handles caching out-of-the-box and I’m curious to see if the wider adoption of RSC reduces the need to combine React with solutions like Apollo Server and Apollo Client. While there are other benefits to these tools, RSC could provide similar caching behavior without the need to invest in a GraphQL solution.

Combining React Server Components and Suspense (along with traditional SSR) should also go a long way toward improving interaction to next paint (INP) for Next.js applications.

📣 Important: As state above, RedwoodJS very recently added RSC support. That team also emphasized the overlap in functionality between RSC and GraphQL.

What are the Disadvantages of React Server Components?

The main disadvantage of React Server Components, in my mind, is the significant increase in complexity.

A portion of this complexity should fade as React developers get accustomed to the new patterns necessitated by RSC. But as currently implemented, I do think RSC reduces the overall ergonomics of React – especially on any page with multiple nested client/server components and contexts.

Another disadvantage lies with RSC support. Since Next.js is the only framework supporting React Server Components, you’re stuck with it for now. And since Next.js works best when deployed to Vercel, some might also argue that narrows your choice of hosting providers.

Outside of JavaScript frameworks, there is a known issue with CSS-in-JS libraries including emotion and styled-components. That problem isn’t insurmountable, but will complicate incremental adoption for large, established code bases. Poor server component support by other libraries will also have a similar effect.

Are React Server Components Production Ready?

My answer here would be a solid “yes, but…”.

If you’re using Next.js or plan to use Next.js, then React Server Components are production ready. There are definitely rough edges in the Next.js implementation, but I don’t think that’s a show stopper.

That being said, I suspect that greenfield projects will have an easier time adopting RSC since they can pick and choose compatible libraries. Established codebases may find it very time consuming to update their existing code to cooperate with RSCs, especially given the dearth of libraries supporting it.

ℹ️ Sidenote: If you want more insight on this, check out Are we RSC yet?.

If you are not using Next.js and do not plan to, I would personally advise against implementing RSC into production code until support has improved.

Open Questions About React Server Components

In writing this article, I began forming some questions that I don’t yet have the answer to. These include:

  • How much will the combination of React Forget and React Server Components improve React performance overall? Will they impact or compliment one another at all?
  • How will React Server Components work with Expo and React Native?
  • How does Turbopack fit into this? Will it bring RSC functionality to other frameworks?

If you know anything about these topics, please feel free to let me know! I’d love to learn from you.

Further Resources and Special Thanks

Thanks for sticking with me until the end of a long article! Here are a few additional resources I came across if you’d like to learn more about React Server Components:

I’d also like to extend my thanks to multiple people who provided feedback and pointed me toward resources while writing this article. Thank you for the help and I hope this was an enjoyable read!

Learn more about how The Gnar builds React  applications.

Interested in building with us?