React Server Components (RSCs): A Complete Guide for Beginners

This post has everything you need to learn React Server Components, otherwise known as "RSCs". Most importantly, you'll learn how they can help you!

Last UpdatedMarch 29, 2024
Estimated Read Time17 minutes

Article image
exampe

What are React Server Components?

React Server Components enable you to build components that render only on the server, providing benefits like reduced bundle size and direct access to server-side data sources.

In other words, we get all the benefits of server-side templating languages like PHP, EJS, Handlebars, Mustache, etc. but now, we get to use React!

RSC Example

Server components are just like traditional React components on the surface. The only visible change you'll notice is that I'm using async/await syntax directly inside a React component! That's cool.

import db from './database';

export default async function Message() {

  // Async, server-side data fetching directly in a React component!
  const message = await db.getMessage();
  
  return (
    <div>
      Server message: {message}
    </div>
  );
}

We've got a lot more to talk about here.

The Evolution of React: From client-side to server-side

To understand why we need RSCs, we need to understand how we got here.

  • Initial React (2013): React launched with traditional, client-side components for building dynamic user interfaces.
  • Shift Towards Hybrid Rendering (2018-2019): Frameworks like Next.js and Gatsby blended server-side and client-side rendering, hinting at React's flexibility.
  • Introduction of Server Components (Dec. 2020): React Server Components were introduced to address the growing need for improved performance and efficiency.
  • Next.js app router (2023+): With the release of the Next.js app router, RSCs became available to all developers, leading to a broader adoption. At the time of writing (Jan 2024), the developer community is still divided on the topic of RSCs and widespread adoption has been met with several challenges regarding the underlying complexity and developer experience of RSCs.

Moving forward, I expect to see other frameworks integrating RSCs natively (Next.js had a first-mover advantage) and better documentation on how to use RSCs without frameworks. That said, we've got a long way to go before everyone is on board.

In the meantime, let's talk about what RSCs are and how they work.

Why React Server Components?

I've been using RSCs since they were released and have tried all sorts of patterns with them. Here are the main benefits that I've experienced.

Benefit #1: Simpler code

In my opinion, the biggest benefit of RSCs is a drastic reduction in code. Let's revisit the example from earlier in the post:

import db from './database';

export default async function Message() {

  // Async, server-side data fetching directly in a React component!
  const message = await db.getMessage();
  
  return (
    <div>
      Server message: {message}
    </div>
  );
}

With RSCs, this is all the code I need to fetch something from a database and render it to the browser.

Without RSCs, things get a bit more complicated:

// Message.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';

function Message() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    // Fetch the message from an API endpoint
    axios.get('/api/message')
      .then(response => {
        setMessage(response.data);
      })
      .catch(error => {
        console.error('Error fetching the message', error);
      });
  }, []);

  return (
    <div>
      Client message: {message}
    </div>
  );
}

export default Message;

Just to name a few things, without RSCs, we have to do all of the following:

  • Make a separate network request for the page and the data
  • Synchronize the client-side and server-side state (i.e. what happens when the database is updated?)
  • Write a separate API endpoint that fetches data from the database

That's a lot of extra work!

React server components simplify data fetching. Period.

Benefit #2: Complete backend access

As you saw in the example above, RSCs have two characteristics that make our lives a lot easier:

  • They can be asynchronous
  • They are executed in the Node.js runtime (i.e. access to backend resources, secret keys, etc.)

In other words, similar to my first point, less, simpler code.

Benefit #3: Better Overall SEO (search engine optimization)

Google Bot, the web crawler that crawls your website and indexes it so others can find your pages prefers server-side rendering.

The less JavaScript it has to execute to figure out what's on the page, the better.

With RSCs (particularly alongside Next.js), we get huge SEO benefits:

  • All metadata is generated server-side
  • Page data can be streamed as it becomes available, which gives you the benefit of server-side rendering while also maintaining a fast TTFB (time to first byte)—an essential metric for user experience.

Benefit #4: Helps avoid data fetching waterfalls (network calls)

In short, RSCs reduce the number of "round trips" required to load a webpage. The less network requests we make, the faster the page loads (especially on slow networks / mobile devices).

Traditional Client-Component Data Fetching Waterfall

With client components, we first load the page from the server, then make additional requests for data.

Article image

Server Component Data Fetching Waterfall

With server components, we load both the page and the data all at once!

Article image

Benefit #5: Smaller Bundle Sizes

Bundle size is a fancy way of saying, "how much JavaScript was shipped to the client".

Web apps lie on a spectrum. On one end, you have single-page applications (SPAs) where JavaScript is required to render pages and routes. This results in a fairly large bundle size that the client (browser) must fetch over a network before the page is fully rendered and interactive.

On the other side is a 100% server-rendered app. This type of app will still send a "payload" to the browser (html, css, and some JS), but the "bundle" (of JavaScript" will be much smaller.

React Server Components contribute to decreasing the bundle size by allowing parts of a React app to be rendered on the server. Unlike traditional components that render on the client side and require all their code and dependencies to be sent to the browser, Server Components run on the server. This means only the result of the rendering - usually HTML - is sent to the client. This reduces the amount of JavaScript the client needs to download, parse, and execute.

The smaller bundle size matters because it leads to faster load times, which is crucial for user experience, especially on mobile devices and slower networks. It also positively impacts performance metrics like Google's Core Web Vitals, which can influence search engine rankings. In summary, React Server Components help create more efficient, performant web applications by reducing the client-side load.

React Server Components vs. Client Components

You're going to hear this one again. As we all move into the era of RSCs, deciding whether we need a server component or a client component will become an active part of any front-end developer's workflow.

How to use React Server Components

Server components are the default in Next.js. At the time of writing, it is unclear how other frameworks will incorporate RSCs, but for now, if you're using Next.js, you can write normal React components and they will be rendered on the server.

Repeating our example from above, here is a valid RSC:

export async function generateMetadata({ params }: Props) {
  const data = await fetchPage(params.slug);

  if (!data) {
    notFound();
  }

  return {
    title: data.title,
    description: data.excerpt,
  };
}

export default async function Page({ params }: Props) {
  const data = await fetchPage(params.slug);

  if (!data) {
    notFound();
  }

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.excerpt}</p>
    </div>
  );
}

Server Component Limitations

Server components cannot have any of the following:

  1. Local State Management: React Server Components cannot use hooks like useState or useReducer.
  2. Effects and Refs: Hooks like useEffect, useLayoutEffect, and useRef are not allowed.
  3. Event Handlers: You cannot attach event handlers like onClick, onChange, etc., directly in server components.
  4. Loading Client Components Directly: Server components cannot import and render client components.
  5. Browser-Specific APIs: Access to browser-specific APIs such as window or document is not possible in server components.
  6. Third-party Libraries Requiring Browser APIs: Libraries that depend on browser-specific APIs cannot be used.

Here's an example of an INVALID server component (this won't render):

// InvalidServerComponent.server.js
import React, { useState, useEffect } from 'react';

export default function InvalidServerComponent() {
  const [count, setCount] = useState(0); // Invalid: useState is not allowed

  useEffect(() => { // Invalid: useEffect is not allowed
    console.log('Component mounted');
  }, []);

  const handleClick = () => { // Invalid: Event handlers are not allowed
    setCount(count + 1);
  };

  return (
    <div>
      <h1 onClick={handleClick}>Click Me!</h1> {/* Event handlers are not allowed */}
      <p>Count: {count}</p>
    </div>
  );
}

When to use React Server Components

In frameworks like Next.js, server components are the default.

In general, if you're able to use a server component, do it.

If you are building a highly interactive dashboard behind authentication, you may not use server components much at all. On the flip side, if you're building a content-driven website, nearly your entire app will be server components.

At the Page Level

As a personal preference and rule of thumb, I rarely use client components at the page level. In a framework like Next.js, data fetching and SEO is much simpler when you can asynchronously fetch data at the page level.

With Next.js and RSCs, here is everything you need to generate <head> tags for SEO and load data into your page:

// SEO metadata
export async function generateMetadata({ params }: Props) {
  const data = await fetchPage(params.slug);

  if (!data) {
    notFound();
  }

  return {
    title: data.title,
    description: data.excerpt,
  };
}

// Page
export default async function Page({ params }: Props) {
  // This will be memoized, so it is only called once despite appearing twice on this page
  const data = await fetchPage(params.slug);

  if (!data) {
    notFound();
  }

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.excerpt}</p>
    </div>
  );
}

At the Component Level

Another cool thing about RSCs is we now have a completely new paradigm where React components can be completely isolated and fetch their own data! This is a pattern that I've used extensively with my content-driven websites.

For example, let's say that you have a main blog post with a section at the end for related content. The related content does not need to be loaded immediately (because it's out of sight on page load). To speed up the page load, we can create an isolated <RelatedContent /> component that fetches its own data and its loading is "suspended" on initial page load.

In other words, in the code below, the blog post will be fetched server-side first, the page will be shown to the user, and then our isolated <RelatedContent /> will fetch related content and show a loading indicator until all the data is "streamed" into the UI.

async function fetchRelatedContent(postId: number){
  return { } // some data
}

async function RelatedContent({ postId }: {postId: number}){
  const related = await fetchRelatedContent(postId);

  return (
    <div>
      {/* Render data here */}
    </div>
  )
}

async function fetchBlogPost(slug: string){
  const post = await fetchPostFromCMS(slug)
  return post
}

async function BlogPostPage({params}) {
  const post = await fetchBlogPost(params.slug)

  return (
    <div>
      <section>
        <h1>{post.title}</h1>
      </section>

      <section>
        <Suspense fallback={<p>Loading...</p>}>
            <RelatedContent postId={post.id} />
        </Suspense>
      </section>
    </div>
  )
}

How to use React Client Components

In Next.js, client components are "opt-in", meaning you must explicitly state that you are writing a client component with the "use client"; directive. Generally, you'll place this at the top of a client component file.

Let's make our invalid server component from above... VALID:

"use client"; // This is REQUIRED

import React, { useState, useEffect } from 'react';

export default function ValidClientComponent() {
  const [count, setCount] = useState(0); // Valid!

  // Rest of component here
}

When to use React Client Components

If you're building highly interactive dashboards, you will use lots of client components.

Here are just a few examples of when you might need a client component.

Example #1: Browser APIs

Let's say you need access to the browser's Navigator API. In this case, a client component is your only option and makes sense:

function LocationDisplay() {
  const [location, setLocation] = useState('');

  useEffect(() => {
    navigator.geolocation.getCurrentPosition(pos => {
      setLocation(`Latitude: ${pos.coords.latitude}, Longitude: ${pos.coords.longitude}`);
    });
  }, []);

  return <div>Current Location: {location}</div>;
}

Example #2: Dynamic Components

If you're building complex forms, interactive content, or anything else that requires client-side state management, you'll need a client component. Ideally, you should colocate all of this logic into a client component and then use a server component as the "container" for it.

For example, we'll make a Counter as a client component in a file called Counter.jsx

"use client";

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <p>Count: {count}</p>
    </div>
  );
}

Then, we can render that inside a server component:

import Counter from './Counter'

function ServerComponent() {
  return (
    <div>
      <p>I am a server component that can fetch data</p>

      {/* Valid to render client component in server component */}
      <Counter />
    </div>
  )
}

How Server Components and Client Components Interact

One of the most confusing things about server components to React devs is how the two interact. While this is largely framework-specific (i.e. how Next.js implements RSCs) and React rendering is fairly complex, the basic idea goes like this:

  1. React (not Next.js) renders each server component into something called a "React Server Component Payload (RSC Payload)"
  2. Next.js now uses all the RSC Payloads + Client Component JavaScript to render HTML server-side
  3. The response is sent to the browser, which again uses a combination of RSC Payloads and client-side JavaScript to render the HTML and hydrate client components with data

This is a massive oversimplification of the entire process.

To better understand it, we need to know what's inside that "RSC Payload". It includes:

  • The rendered HTML of server components
  • "Placeholder slots" where client components need to be rendered + reference to those client components' JavaScript payloads
  • Props that were passed from server -> client components

Once again, a massive oversimplification, but here's what that looks like visually.

Rendering Visualization

Article image

Downsides of React Server Components (with Next.js)

While the first part of this post may have led you to believe how great server components are, as with anything in engineering, there are tradeoffs.

And since at the time of writing, Next.js is the frontrunner for a production-ready implementation of server components, it would be a disservice not to mention a few of the things that are not so great about using RSCs.

Downside #1: Lots and lots of files (client/server boundary)

By far my biggest complaint so far with RSCs is the "per-file" client/server boundary.

Let me paint a picture for you. Imagine you have a page that displays information about a business. A simple RSC, nothing fancy here:

async function getBusiness() {
  return Promise.resolve({} as any);
}

export default async function BusinessPage() {
  const business = await getBusiness();

  return (
    <div>
      <h1>{business.name}</h1>
      <p>{business.phoneNumber}</p>
      <p>{business.description}</p>

      <div>{/* Other static business details */}</div>
    </div>
  );
}

To make the user experience better, let's say you want to make the phone number "clickable" so the user can click to copy it.

Since this is a server component, we know that we're going to need a client component to make that interactivity happen.

Intuitively, you might try something like this:

import { useState } from "react";

function ClickablePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
  const [message, setMessage] = useState(""); // State to show a message after copying

  const copyToClipboard = () => {
    navigator.clipboard
      .writeText(phoneNumber)
      .then(() => setMessage("Phone number copied!"))
      .catch((err) => setMessage("Failed to copy!"));
  };

  return (
    <p>
      Phone: <span onClick={copyToClipboard}>{phoneNumber}</span>
    </p>
  );
}

async function getBusiness() {
  return Promise.resolve({} as any);
}

export default async function BusinessPage() {
  const business = await getBusiness();

  return (
    <div>
      <h1>{business.name}</h1>

      <ClickablePhoneNumber phoneNumber={business.phoneNumber} />

      <p>{business.description}</p>

      <div>{/* Other static business details */}</div>
    </div>
  );
}

It's a rather simple component, so many React devs would throw it in the same file. But this is NOT a valid RSC anymore. In order for this file to run, we'd need a use client; directive that applied to the entire file!

So to use a server component for the business details and a client component for the clickable phone number, we need two files:

  1. ClickablePhoneNumber.tsx - client component
  2. BusinessPage.tsx - server component
"use client"; // REQUIRED!!

import { useState } from "react";

export default function ClickablePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
  const [message, setMessage] = useState(""); // State to show a message after copying

  const copyToClipboard = () => {
    navigator.clipboard
      .writeText(phoneNumber)
      .then(() => setMessage("Phone number copied!"))
      .catch((err) => setMessage("Failed to copy!"));
  };

  return (
    <p>
      Phone: <span onClick={copyToClipboard}>{phoneNumber}</span>
    </p>
  );
}
import ClickablePhoneNumber from "./ClickablePhoneNumber";

async function getBusiness() {
  return Promise.resolve({} as any);
}

export default async function BusinessPage() {
  const business = await getBusiness();

  return (
    <div>
      <h1>{business.name}</h1>

      {/* Now it works! */}
      <ClickablePhoneNumber phoneNumber={business.phoneNumber} />

      <p>{business.description}</p>

      <div>{/* Other static business details */}</div>
    </div>
  );
}

In other words, when you're building apps that intermingle lots of server and client components, you not only have to create lots of files; you have to change you entire strategy for building layouts.

Over time, you'll get used to this. But in the beginning, if you bring your traditional React patterns with you, it's going to be painful.

Downside #2: Did the bundle size really improve?

I mentioned that bundle size was one of the benefits of React server components earlier in this post. But some early criticism with the Next.js implementation is that overall bundle size for a "Hello World" app has increased.

This is more of a framework-specific critique than a React server component critique.

Before server components (pages router), the initial Next.js bundle size was ~70KB. Now, with server components (app router), it is ~85KB.

I'm not a huge fan of this argument. While a non-trivial increase in bundle size for a starter app, it kind of misses the point. If you're going to use a framework with as many features as Next.js, you won't be winning any awards on initial bundle size. If that is your top priority, use a different framework (or better, none at all). Furthermore, 1) this is not sizeable enough for an end user to care 2) as your app grows, the effect of this initial bundle size decreases.

Limitation #3: Complexity and Learning Curves

RSC Payloads...

Full route cache...

Data cache...

"use client" directive...

Streaming and suspense...

Server actions...

These are all relatively new to Next.js, and I don't think you'll find any developer out there saying, "the app router is much simpler than the pages router".

With the introduction of RSCs, Next.js as a framework has a much steeper learning curve. Suddenly, a frontend React developer must learn several backend concepts just to keep up with the features Next.js offers. It is an incredibly hard framework to teach to a complete beginner.

As a shameless plug, that's why I created Full Stack Foundations. I want to bridge that gap as we move towards a "full-stack-centric" world.

Framework adoption of RSCs

As mentioned above, Next.js is the frontrunner in adopting RSCs. That said, we've seen announcements from Redwood.js, Gatsby, and Remix on their adoption plans, so I'd expect some flavor of RSCs to be released to stable branches of each of these in the next year or so.

Concluding Thoughts

While React Server Components have stirred up all kinds of debate in the web development community, I think they are an overall step forward for the React ecosystem.

I've been using them in production with several of my content-driven websites for a while now and have enjoyed the developer experience they have brought to Next.js.