I'm available to deliver your SaaS MVP in a few weeks, full-stack with the new Next.js App Router. Accelerate Your MVP Launch Now.

React Server Components and a new hybrid web app model

Learn why React Server Components and the new Next.js App Router unlock a new hybrid web application model, why it makes sense, and how to use it.

Flavio Silva
Flavio Silva β€’ December 18, 2023
Updated on February 8, 2024
React Server Components and a new hybrid web app model
Image by rawpixel.com on Freepik

Table of Contents

Introduction

In the first half of this year, Vercel released Next.js v13.4, marking stability for the new App Router and React Server Components (RSCs) and making Server Actions available in alpha. Later, in October, Vercel released Next.js v14, making Server Actions stable.

Those new features represent a major technological innovation that has taken React and Next.js teams several years to research and develop. As Andrew Clark put it, that's the real React 18 release, and those new features are the primitives that will unlock the next layer of innovation for React apps, unfolding a new hybrid web application model.

In this article, we'll first take a step back to understand how we got here and why this new model makes sense.

Then, we'll learn what React Server Components are, their benefits and constraints, when to use Server and Client components, what Server Actions are, and how to assemble everything to build full-stack React components and hybrid web applications to solve four important problems with the Next.js Pages Router.

To this date, Next.js App Router is the most comprehensive and only production-ready implementation of React Server Components (RSCs) and other React 18 features like Server Actions and Suspense (integrated into RSCs). Therefore, I'll base the examples on a Next.js App Router implementation.

A brief overview of web application models

1990s

In the 1990s, we took a server-centric model of rendering everything on the server (SSR) and returning simple HTML pages to the user that updated the entire page on every interaction, providing a poor user experience by today's standards.

2000s

In the 2000s, we started writing asynchronous JavaScript code to fetch XML data using the XMLHttpRequest API, known as AJAX, and updating only the parts of the page that needed to change with jQuery, providing a much better user experience.

Things started to become interesting at this point, with web applications starting to rival desktop ones. Gmail was launched in 2004.

But the JavaScript code became spaghetti code. We were trying to build Single-Page Applications (SPAs) without the tools.

2010s

But they came, eventually, in the 2010s. Backbone.js, AngularJS, and React were launched. As developers, we eagerly welcomed and fully embraced them.

SPAs became the norm. We took a client-centric model of rendering everything on the client (CSR). In this model, we render nothing on the server, returning an empty HTML instead, having the client load and execute a large amount of JavaScript code to make several HTTP requests to the server to fetch data, and finally, rendering an interactive HTML application that only updates the necessary parts of the page! πŸ˜…

And this time, we're not eating spaghetti! Long-live SPAs! πŸŽ‰

But wait, something is not quite right.

By going all-in CSR / SPA, we traded a few critical features, including fast initial page load and SEO support. We also introduced complex data fetching and state management logic on the client side and an API layer on the server side.

2016: Next.js FTW

In 2016, Next.js was released to fix those two critical issues in React apps: slow initial page load and poor SEO. It succeeded by implementing its flagship server-side rendering (SSR) feature.

Next.js evolved to help developers build better client-side apps more efficiently, including support for client-side routing, pre-fetching and pre-rendering pages, and automatic code splitting.

In 2019, it transitioned into a full-stack web application framework by introducing support for creating backend services through its API Routes feature as part of the Next.js 9 release.

Next.js Pages Router's issues

The Next.js framework we're discussing was later named Pages Router.

And it turns out the SSR solution in Pages Router has its own issues.

Pages Router's issue #1
  • We have to use proprietary getServerSideProps() and getStaticProps() functions at a top-level page component to fetch data server-side and drill it down to the necessary components. We cannot fetch data server-side at the component level.
Pages Router's issue #2
  • We must fetch data for the entire page before rendering our components server-side.
Pages Router's issue #3
  • The client gets a server-side rendered static HTML page to show the user, but it must download the JavaScript code for the entire page before React can hydrate the components, i.e., making them interactive.
Pages Router's issue #4
  • React must hydrate the entire page before the user can interact with it.

Those issues are also explained here, with issues #2, #3, and #4 representing a waterfall problem that spans from the server to the client, where each issue must be resolved before moving to the next one.

On top of that, we still have to deal with all the complexities of client-side data fetching, state management, and server-side API layers with the Pages Router.

We were still transitioning from the SSR+SPA model, but transitioning to what? Those issues are a lot for the React and Next.js teams to fix.

A new hybrid web application model

2023: Next.js App Router FTW

By introducing React Server Components and Server Actions, React 18 and the new Next.js App Router have combined the traditional server-centric SSR model of the 1990s with the modern client-centric CSR model of the 2010s in a cohesive way, offering a new hybrid web application model that promises to bring the best of both worlds.

This new model will unlock the next layer of innovations for the React ecosystem while helping eliminate much of the complexity we've been adding to web applications since the 2010s.

Now, we can compose a hybrid React component tree rendering on both the server and the client. That brings React's composability flagship feature to the server, allowing us to seamlessly fetch data on the server and mutate data on the client without a server-side API layer and the complexity of client-side data fetching and state management logic.

We're moving parts of the code back to the server while removing others, making the client and the server lightweight again.

Hybrid web applications promise to bring the best of both worlds while reducing complexity, code, and costs.

Can it deliver on its promises?

Let's learn React Server Components and how to use them with the new Next.js App Router to build hybrid web applications and fix those four issues.

What are React Server Components (RSCs)?

A React Server Component is a new kind of React component designed to run only on the server (or at build time) and render static HTML[1] that's never hydrated on the client, extending the React programming model.

React is now a full-stack component-based solution!

It's a rewarding time to be a full-stack engineer, as we've just got a fantastic new tool.

React Server Components are React components only on the server. On the client (browser), they're plain old static HTML. That's why they can't have state or handle button clicks.

They're stateless and non-interactive. Their JavaScript code only runs on the server (or at build time) and is never shipped to the browser.

React can only update Server Components on the server where their JavaScript code runs. And it does that by using the traditional request/response lifecycle of the web (more on this later).

With the advent of Server Components, React now offers two kinds of components: Server and Client Components.

[1] Technically, React renders Server Components into a special data format called the React Server Component Payload (RSC Payload), and in a later step renders that data into HTML. That's necessary for React to keep the current Client Components' state while re-rendering a hybrid React component tree, i.e., a component tree composed of Server and Client components.

What are Client Components?

Server Components don't replace Client Components.

Client Components are the React components we're used to. They haven't changed.

They're the components we use when we need interactivity, state, hooks, and browser APIs. We can now weave Client and Server components in a hybrid React component tree.

Moving forward, we'll see how to declare Server and Client components in our code and when to use each one.

React Server Components vs SSR

React Server Components don't replace SSR. While Server Components are server-only, i.e., they only render on the server and never render or hydrate on the client, Client Components still render on the server for fast initial page load and SEO support and hydrate on the client for interactivity.

React Server Components constraints

Server Components have numerous advantages, but they also come with various limitations.

Hooks

We cannot use hooks in Server Components.

No useState(), useEffect(), useContext(), etc.

Hooks are functions that let you "hook into" React state and lifecycle features, and custom hooks allow us to reuse stateful logic among components.

But Server Components are stateless and have no lifecycle, so hooks don't apply to them. We use plain-old JavaScript functions to share logic among Server Components.

Do you need a hook? Then, you need a Client Component. Hooks are still great for Client Components.

Interactivity

We cannot handle interactions in Server Components, like onClick(), onChange(), etc. Remember, they run only on the server. We need the browser to handle interactions.

Do you need to handle an interaction? Then, you need a Client Component.

However, we can pass Server Actions to interactive events like onClick()β€”more on this next.

Browser APIs

We cannot access browser APIs like the DOM or localStorage() in Server Components.

Do you need to access a browser API?

You know what you need.

Continuous updates

We cannot implement continuous updates like WebSockets with Server Components.

Yep, Client Components again.

Data serialization

We can only pass serializable data from Server Components to Client Components. Remember that when we pass something from a Server Component to a Client Component, we pass it from the server to the browser through the network.

We cannot pass functions, like event handlers, from Server Components to Client Components, but we can pass Server Actions.

Server Actions provide a seamless Remote Procedure Call (RPC) implementation in JavaScript, meaning a Client Component makes an HTTP request behind the scenes to invoke a Server Action. And that's awesome!

Too many constraints?

That's a lot of constraints; are React Server Components that helpful?

Well, having such clear boundaries between Client and Server components is fantastic. It makes it easier to figure out which one to use.

A good practice is to use Server Components as much as possible due to their numerous benefits, including zero impact on bundle size and server-side data fetching and moving Client Components down the tree. When we hit one of the constraints of Server Components, we weave in a Client Component.

React Server Components benefits

Server Components offer many benefits, including:

Security

The JavaScript code of Server Components stays on the server, making them perfect for handling sensitive data and preventing unintentional leaks.

Zero-Bundle-Size impact

Since their JavaScript code is never shipped to the browser, Server Components have zero impact on bundle size.

By embracing Server Components, your bundle size doesn't grow linearly as your app grows with new features as it does with Client Components.

You can also consume heavyweight dependencies on the server without affecting the user experience.

Automatic client code splitting

A Server Component may import different Client and Server Components and perform some conditional logic that renders only some of them. Only the JavaScript code of the rendered components is shipped to the browser.

SSG

In Next.js App Router, Server Components render at build time by default unless you use a dynamic function like cookies() or headers(), which will opt into dynamic rendering.

We can read static data from Server Components at build time from different sources, including the filesystem, remote APIs, and databases.

That means we don't need a server to use Server Components, and we can use them to generate static HTML pages and complete static websites that can be served from a CDN, making them the ultimate React SSG tool!

Server-side data fetching

Server-side data fetching is the most important feature of Server Components.

"We originally thought of Server Components as a way to solve the waterfall problem." Dan Abramov, on Dec 21st, 2020.

The network waterfall problem is a data-fetching problem that happens when an app makes multiple data-fetching requests one at a time. Hence, users must wait for them one at a time to resolve, slowing down the application's performance.

With Server Components, we can fetch data server-side multiple times and render the UI on a single client-server roundtrip, enhancing the application's performance by drastically reducing or eliminating the network waterfall problem.

Additionally, we can fetch data server-side at the component level instead of fetching everything at a top-level page.tsx component using the getServerSideProps() and getStaticProps() functions. We can fetch data from any Server Component anywhere in the component tree. That fixes the Next.js Pages Router's issue #1.

To fetch data from a Server Component, we turn it into an async function.

We can use third-party libraries to access databases directly or use the standard JavaScript fetch function that Next.js extends to allow developers to configure the caching and revalidating behavior for each fetch request.

We don't need to create an API layer to fetch data from Server Components.

Client Components can still fetch data, and we can use Route Handlers in the server to handle it. However, we should fetch data on the server whenever possible.

The key to unlocking a new hybrid web application model

By introducing React Server Components, React allows us to choose where to render each component, whether in the server or client, combining the best of each environment and unlocking a new hybrid web application model.

Server Components must be pure functions

Server Components must be pure functions like Client Components, i.e., we shouldn't cause side effects like mutating data when rendering Server Components. We should be able to render a Server Component many times and get the same result every time.

We should handle mutations with Server Actions in response to external events, like user interactions.

How to write Server and Client Components

Next.js App Router is server-first, with Server Components default and Client Components opt-in. We have to determine what components React should render on the client explicitly.

We do that by using React's "use client" directive at the very top of our components to declare a network boundary between a Server and a Client Component.

Once we set that boundary, all components imported by the Client Component that contains "use client" are treated as Client Components. That means we don't need to "use client" on every Client Component.

To make sure the code of Server Components never slips into the browser, we should install and use the server-only package and use it in our Server Components like this:


import 'server-only';

By doing that, we get an error at build time when that boundary is violated by a Client Component importing a Server Component.

We also have its counterpart, the client-only package.

We should only use those packages when using server or client-only APIs. Some components don't use server or client-only APIs; we call them Shared Components.

What are Shared Components?

Shared Components are regular React components that do not depend on either server or client-only APIs and do not define "use client" or "use server", so both Server and Client Components can import them.

Take the following example:

Header.tsx

import { Logo } from './Logo';
export const Header = () => {
return (
<header>
<Logo />
</header>
);
};

When a Server Component imports <Header>, it behaves like a Server Component, i.e., it renders to HTML on the server, its JavaScript code is never shipped to the browser, and it never hydrates.

When a Client Component imports <Header>, it behaves like a Client Component, i.e., it renders server-side for fast initial page load and SEO support and hydrates on the client for interactivity, so its JavaScript code is shipped to the browser.

That gives us even more flexibility, and we should strive to have as many Shared Components as possible. But once we need a server or client-only API, we must remember to import 'server-only' or import 'client-only' to ensure everything behaves as expected and, most importantly, no sensitive server code leaks to the browser.

When to use Server and Client Components

We should use Server Components as much as possible due to their numerous benefits, including zero impact on bundle size and server-side data fetching, moving Client Components down the tree.

When we hit one of the constraints of Server Components, we weave in a Client Component.

Interactivity is key most of the time. When you need it, you use Client Components. Otherwise, you use Server Components. Check this table for a quick summary of the different use cases for Server and Client Components.

This approach is also known as the Islands Architecture.

Following this new mental model is tricky initially, but it becomes easier as we get used to it. The constraints on client and server features indicate what to use.

Interleaving Server and Client Components

We're not restricted to putting Client Components only to the leaves. We can also interleave Server and Client components, but there are constraints.

Server Components can import Client Components, but Client Components cannot import Server Components. However, they can receive Server Components as props, e.g., children, to render them in their tree.

Take this example:

page.tsx

import { Dialog } from './Dialog';
import { ProjectForm } from './ProjectForm';
export default function NewProjectPage() {
return (
<Dialog>
<ProjectForm />
</Dialog>
);
}

In the example above, NewProjectPage is a Server Component, <Dialog> is a Client Component, and <ProjectForm> is a Server Component.

That's possible because React reconciles Server Components rendered on the server with Client Components rendered on the client.

By balancing Server and Client Components, we can create a high-performance, efficient, and engaging application.

Building full-stack React components

We no longer need to use the proprietary getServerSideProps() and getStaticProps() functions at the top-level page component to fetch data on the server and drill it down to the necessary components.

We can now build highly reusable and composable full-stack components that fetch data and render the UI in the server in a single roundtrip with Server Components.

Take a <TaskList> component, for example. It's supposed to render a list of tasks. So, why can't we have it fetch its data?

Well, we can.

We can have it fetch its data on the client side; that's always been possible.

However, we want the SSR benefits, so we need to fetch data on the server.

Fine, let's fetch data on the server. It's not a big deal.

Well, it turns out that before the App Router, we had to use getServerSideProps(), which must come at the top-level page component. That means we cannot have <TaskList> fetch its data if we want to fetch data on the server. πŸ™„

That also means the <TaskList>'s composability is hurt because we cannot just throw it in a component tree. We must getServerSideProps() and drill it down whenever we use it! 😣

Enter Server Components and App Router. πŸ’‘

With Server Components, we can effortlessly fetch data on the server at the component level for the first time ever! 🀯

We can have our cake and eat it too!

How cool is that? 🀀

We can turn <TaskList> into an async Server Component and have it fetch its data and render the UI on the server.

Let's see some more code to have a better idea:

TaskList.tsx

import Link from 'next/link';
import { getTasks } from './actions';
export const TaskList = async () => {
const tasks = await getTasks();
return (
<div>
{tasks.map((task) => (
<div key={task.id}>
<Link href={`/app/tasks/${task.id}`}>{task.name}</Link>
</div>
))}
</div>
);
};

actions.ts

'use server';
export const getTasks = async () => {
return await prisma.tasks.findMany();
};

And that's it.

We can now throw it in a hybrid component tree and not worry about its data dependencies; it takes care of itself.

It's a Lego block again, supercharged with server-side data fetching capabilities.

Since it's coupled with data-fetching logic, we should design and expose a clean API for consumer components to filter what task data they want.

Here are a couple of examples extracted from OpenTask:


<TaskList byProject={projectId} only="completed" />


<TaskList
dueOn={new Date()}
only="incomplete"
onlyProjects="active"
>

In a large-scale application or a suite of applications, we can now design, reuse, and compose components like that anywhere in the component tree just by rendering, e.g., <TaskList byProject={projectId} />.

That's the power of React's composability on the server side.

I call this component a full-stack component: a self-contained data-driven Server Component that's fully reusable and composable despite its data dependencies.

That new pattern works exceptionally well for reducing complexity and code while keeping components lightweight.

You should see Sam Selikoff's brilliant talk on this topic in the Next.js Conf 2023 if you haven't.

Different data sources

Let's say an application has different data sources to render the same <TaskList> component. That's a great use case to make it a dumb UI component that receives task data from the outside. But that's rare.

Note that <TaskList> cannot be decoupled from task data. Its purpose is to render such data.

Most web application components are like that, which makes this pattern even more helpful.

Server Actions

Server Actions are the React way to mutate data, built-in. They're server-side asynchronous JavaScript functions that provide a seamless Remote Procedure Call (RPC) experience between the client and the server. We can call Server Actions directly from Client Components to mutate data without building an API layer.

Server Actions are stable in Next.js v14; we no longer need to set it up in next.config.js.

We can define Server Actions in Server Components or a separate file. Let's see an example of a Server Action in a separate file:

actions.ts

'use server';
export const createTask = async (formData: FormData) => {
const data = Object.fromEntries(formData);
return await prisma.tasks.create(data);
};

When defining Server Actions in a separate file, we must "use server" at the top of the file and only export async functions.

How to re-render Server Components

Server Components only render on the server. On the client, they're plain old static HTML. Their JavaScript code only runs on the server.

That means React cannot re-render them on the client.

The Request/Response Lifecycle

We use the request/response lifecycle of the traditional web to re-render Server Components.

In a hybrid React web application, the code flow is unidirectional from the server to the client.

Consider a use case where a user visits a web page at /app/tasks.

The following page component renders it:

page.tsx

export default function TasksPage() {
return (
<>
<h1>Tasks</h1>
<TaskList />
<TaskForm />
</>
);
}

All those components can be Server Components. <TaskList> fetches a list of tasks and renders it server-side.

<TaskForm> can also be a Server Component:

TaskForm.tsx

import { createTask } from './actions';
export const TaskForm = () => {
return (
<form action={createTask}>
<input type="text" name="name" />
<button type="submit">Add task</button>
</form>
);
};

However, after submitting the form and creating a task, <TaskList> won't automatically re-render to fetch an updated list of tasks and update the UI.

Using revalidatePath() and revalidateTag()

Fortunately, Next.js provides a few straightforward ways to solve that. In our createTask() Server Action, we can call revalidatePath() or revalidateTag() functions:

actions.ts

'use server';
export const createTask = async (formData: FormData) => {
const data = Object.fromEntries(formData);
await prisma.tasks.create(data);
revalidateTag('tasks');
};

That will trigger a re-render from top to bottom of the application, re-rendering <TasksPage> on the server and everything inside it. That means <TaskList> will also re-render, fetching an updated list of tasks and updating the UI.

All of that happens in a single client/server roundtrip: the client sends an HTTP request to the server to create a task, the server processes the request, saves a new task to a database, re-renders Server Components, and sends an HTTP response back to the client with the result, which the client then merges into the UI while preserving its state.

Using router.refresh()

Another way to re-render Server Components, this time from a Client Component, is by using router.refresh().

React preserves the UI state

It's important to note that in any of the scenarios mentioned above, React will preserve the UI state, so it's not the same as doing a hard reload.

And just like that, we can seamlessly fetch data server-side and mutate data client-side without the complexity of client-side data fetching and state management logic and a server-side API layer.

However, we're still fetching data for all Server Components before rendering the application; the Pages Router's issue #2. Let's see how to solve that.

Suspense & Streaming

Suspense is a React feature that lets us declaratively display a fallback UI (e.g., skeleton, spinner) when we suspend a component's rendering while it executes an asynchronous operation, usually data fetching.

Take the following example:

page.tsx

export default function TaskDialogPage({ params: { taskId } }: { params: { taskId: string } }) {
return (
<Dialog>
<TaskCard taskId={taskId} />
<TaskComments taskId={taskId} />
</Dialog>
);
}

That's a lovely hybrid tree.

<TaskDialogPage> is a sync Server Component, it doesn't fetch any data.

<Dialog> is a Client Component, and <TaskCard> and <TaskComments> are async Server Components that fetch their data. That means we must wait for two data fetching requests to complete before seeing the <TaskDialogPage>.

However, comments are usually not a critical piece of information. We could display our page and show a loading state while <TaskComments> fetches its data and renders itself.

Suspense lets us do just that. All we have to do is wrap <TaskComments> in <Suspense>:


<Suspense fallback={<TaskCommentsSkeleton>}>
<TaskComments taskId={taskId} />
</Suspense>

And that's it. React and Next.js will stream the rendered <TaskComments> component once it's ready, replacing the fallback. That's Streaming Server Rendering.

That effectively solves Pages Router's issues #2 and #3.

We don't have to wait for <TaskComments> to render the page, and the browser will only download its JavaScript code later. And since the browser only downloads its JavaScript code later, it's also hydrated later, thanks to React's Progressive Hydration (or Selective Hydration), which is powered by Concurrent React, solving Pages Router's issue #4.

And the puzzle is complete.

React and Next.js provide sophisticated yet fairly simple-to-use tools.

"Streaming is particularly beneficial when you want to prevent long data requests from blocking the page from rendering as it can reduce the Time To First Byte (TTFB) and First Contentful Paint (FCP). It also helps improve Time to Interactive (TTI), especially on slower devices." ("Loading UI and Streaming")

We can also leverage Suspense without using the <Suspense> component by using App Router's loading.tsx files in our route segments so that users can see a loading state during routing navigations too.

"In a nutshell, React Suspense enables developers to create slots at the rendering tree which will be filled by asynchronous components once data is available for them." ("React Server Components: Concepts and Patterns")

Partial Prerendering (PPR)

Next.js 14 introduced Partial Prerendering as an experimental feature designed to work out-of-the-box with Suspense. We won't have to cha