React Server Components Explained: How the Server/Client Boundary Works in Next.js

React Server Components explained - CodingHumans

React Server Components (RSC) change where your React code runs. Some components now run only on the server and send no JavaScript to the browser. Others run on both sides, like before. The Next.js App Router uses Server Components by default. So if you build with it, you are already using them, even if you haven't thought about the boundary yet.

This post explains how Server Components work, how the 'use client' boundary behaves, what can and cannot cross it, and the mistakes behind most bugs and oversized bundles.

1. What a Server Component is

A Server Component is a React component that renders only on the server, either at build time or on each request. Since it never runs in the browser:

  • It can be async and await data directly, like a database query, a file read or an internal API call.
  • It can safely use secrets like API keys and database credentials.
  • Its code and its dependencies (a markdown parser, a date library, a syntax highlighter) add zero bytes to the client bundle.

The catch: a Server Component cannot use state, effects, event handlers, context, or browser APIs like window and localStorage. It renders once per request, and that's all.

// app/blog/[slug]/page.tsx (a Server Component by default)
import { db } from '@/lib/db';
import { marked } from 'marked'; // never sent to the browser

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
  return <article dangerouslySetInnerHTML={{ __html: marked(post.body) }} />;
}

2. The 'use client' boundary

Client Components are the React components you already know. They can use hooks and handle events. You mark them by putting 'use client' at the very top of a file.

'use client';
import { useState } from 'react';

export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}

Two details trip people up:

  • The directive marks a module boundary, not one component. Every module that a 'use client' file imports also goes into the client bundle. You don't need to repeat the directive in each child file.
  • Client Components still render on the server first. On the first load, Next.js renders them to HTML on the server and then hydrates them in the browser. "Client" means "also runs in the browser", not "only runs in the browser".
Diagram of the server and client component boundary in a Next.js product page
Server Components render the page and data. A 'use client' file starts a client subtree.

3. What can cross the boundary

When a Server Component renders a Client Component, the props travel over the network. So React must be able to serialize them:

  • Allowed: strings, numbers, booleans, null, plain objects and arrays of these, Date, Map, Set, Promises, JSX elements, and Server Actions.
  • Not allowed: normal functions (event handlers too), class instances, and symbols that are not registered globally.

If you need to pass a callback, move the logic into the Client Component, or pass a Server Action instead. You can also pass a Promise from a Server Component and unwrap it in a Client Component with use(). That way the page starts streaming before the data is ready.

4. Composition: server inside client

A Client Component cannot import a Server Component, because anything it imports becomes client code. But it can render one that was passed in as children or another prop:

// Modal.tsx
'use client';
export function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return open ? <div className="modal">{children}</div> : <button onClick={() => setOpen(true)}>Open</button>;
}

// page.tsx (Server Component)
<Modal>
  <ProductReviews id={id} />  {/* stays a Server Component */}
</Modal>

The Server Component renders on the server, and its output is placed inside the client wrapper. The same trick solves context. Create a small 'use client' Providers component that wraps children, and use it in your root layout. Everything inside can still be a Server Component.

5. What actually gets sent to the browser

For a page built with Server Components, the server produces two things:

  1. HTML for a fast first paint, as with SSR.
  2. The RSC payload: a compact description of the rendered tree. Server Component output shows up as plain elements. Client Components show up as references to their JavaScript chunks, along with their props.

The browser downloads JavaScript only for Client Components, hydrates them, and uses the payload to reconcile the tree. On client-side navigation, Next.js fetches only the RSC payload for the new route, so layouts and client state stay as they were.

Add <Suspense> and the server can stream. It sends the page shell right away, then streams in each slow section (reviews, recommendations) as its data arrives. The user doesn't wait for the slowest query.

6. Server Actions and 'use server'

'use server' does not mean "this is a Server Component". It marks Server Functions (often called Server Actions). These are async functions that run on the server but can be called from the client, for example from a form.

// app/actions.ts
'use server';
export async function addComment(formData: FormData) {
  const text = formData.get('text');
  await db.comment.create({ data: { text: String(text) } });
  revalidatePath('/blog');
}

// In a component
<form action={addComment}>
  <textarea name="text" />
  <button type="submit">Post</button>
</form>

Treat every Server Action like a public API endpoint. Anyone can call it, so validate the input and check authentication and authorisation inside it.

7. Common mistakes

  • Putting 'use client' on a layout or page. That turns the whole subtree into client code. Move the directive down to the small interactive components at the edges.
  • Leaking server code into the client. If a client file imports a module that reads process.env.SECRET, Next.js blanks out server-only env variables in the client bundle, so the value usually turns up as undefined and the bug is easy to miss. Add import 'server-only' to server modules, so the build fails if a client file imports them.
  • Passing functions as props from a Server Component to a Client Component. Use a Server Action, or move the handler into the client.
  • Fetching in a useEffect when a Server Component could fetch the data. You end up with a loading spinner and a waterfall you didn't need.
  • Awaiting one request after another, which creates waterfalls. Start independent requests together with Promise.all, or split them into separate Suspense boundaries.
  • Using a third-party component that needs hooks directly in a Server Component. If the library doesn't add 'use client' itself, re-export the component from your own client file.
Key takeaways
  • Server Components run only on the server, can await data, and add no JavaScript to the bundle.
  • 'use client' marks a module boundary. Everything it imports joins the client bundle.
  • Props crossing the boundary must be serializable. Functions are not, except Server Actions.
  • Pass Server Components into Client Components as children instead of importing them.
  • 'use server' is for Server Actions, not for marking Server Components.

Interview questions and FAQ

What is the difference between Server Components and SSR?

SSR renders components to HTML on the server and then hydrates all of them in the browser. Server Components are never hydrated and never send code. In Next.js you get both: Server Components for the non-interactive parts, and SSR plus hydration for Client Components.

Can a Server Component use useState or useEffect?

No. They render once on the server, so there is no state to hold and no browser to run effects in. Move that logic into a Client Component.

Can a Client Component render a Server Component?

Yes, if a Server Component passes it in as children or another prop. It cannot import one directly.

How do you share data between a Server Component and a Client Component?

Pass serializable props down. For global client state, wrap the tree in a client-side context provider. To send updates back to the server, use Server Actions.

Do Server Components replace data libraries like React Query?

For the first data on page load, often yes. For client-side needs like polling, infinite scroll, optimistic updates and caching between interactions, a client data library is still useful.

Comments

Answered 0/0Correct 0