Web Development 7 min read 2,571 views

React Server Components in Production: The Boundary Is Everything

RSC in practice — what "use client" really means, the composition pattern that stops a whole tree turning into client code, serialisation limits, and when not to use it.

React development illustration

Almost every React Server Components problem is the same problem wearing a different hat: the client boundary is in the wrong place. Get that right and RSC is straightforward. Get it wrong and you have shipped a normal client app with extra steps.

This guide is about that boundary. Versions at the time of writing: React 19.2 and Next.js 16.

What RSC actually removes

A Server Component runs on the server and never reaches the browser. Not its code, not its dependencies.

// app/posts/page.tsx — a Server Component by default
import { marked } from 'marked'          // never sent to the browser
import { db } from '@/lib/db'            // never sent to the browser

export default async function PostsPage() {
  const posts = await db.post.findMany({ take: 20 })

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id} dangerouslySetInnerHTML={{ __html: marked(p.body) }} />
      ))}
    </ul>
  )
}

That marked import is the point. In a traditional React app, a Markdown parser is part of your bundle and every visitor downloads it. Here it runs on the server and the browser receives HTML.

The saving is not shaving kilobytes — it is that entire categories of dependency stop being your users' problem.

The rule nobody explains properly

'use client' does not mark one component as client-side. It marks an entry point, and everything imported below it becomes client code too.

// ❌ This turns the whole page into a client component
'use client'

import Chart from './Chart'          // now client
import DataTable from './DataTable'  // now client
import { formatCurrency } from './utils'  // now bundled too

export default function Dashboard({ data }) {
  const [tab, setTab] = useState('overview')   // the only reason for 'use client'

  return (
    <>
      <Tabs value={tab} onChange={setTab} />
      <Chart data={data} />
      <DataTable rows={data.rows} />
    </>
  )
}

One useState for a tab pulled the chart library, the table and the utilities into the bundle. This is the single most common RSC mistake, and it is invisible — the page works perfectly. It is just no longer a server-rendered page in any meaningful sense.

The composition pattern that fixes it

Push the boundary down, and pass Server Components through as children:

// app/dashboard/page.tsx — stays a Server Component
import Tabs from './Tabs'              // small client island
import Chart from './Chart'            // stays on the server
import DataTable from './DataTable'    // stays on the server

export default async function Dashboard() {
  const data = await getDashboardData()

  return (
    <Tabs>
      <Chart data={data} />
      <DataTable rows={data.rows} />
    </Tabs>
  )
}
// app/dashboard/Tabs.tsx
'use client'

export default function Tabs({ children }) {
  const [tab, setTab] = useState('overview')

  return (
    <div>
      <nav>{/* tab buttons */}</nav>
      {children}          {/* already rendered on the server */}
    </div>
  )
}

The rule worth memorising: a Client Component can render Server Components passed as children — it just cannot import them. Children arrive as already-rendered output, so the boundary stops at Tabs and the chart never enters the bundle.

Data fetching, and the waterfall

Server Components can be async, so fetching happens where the data is used. That removes prop drilling — but it makes it very easy to serialise requests by accident:

// ❌ Sequential — each await blocks the next
const user = await getUser(id)
const orders = await getOrders(id)
const recommendations = await getRecommendations(id)

// ✅ Parallel — one round trip's worth of latency
const [user, orders, recommendations] = await Promise.all([
  getUser(id),
  getOrders(id),
  getRecommendations(id),
])

Independent requests should always be a Promise.all. This is easy to miss because the sequential version reads more naturally and behaves identically on a fast local database.

Where a request is genuinely slow, stream it rather than blocking the page:

import { Suspense } from 'react'

export default function ProductPage({ id }) {
  return (
    <>
      <ProductDetail id={id} />               {/* fast, renders immediately */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />                   {/* slow, streams in */}
      </Suspense>
    </>
  )
}

What can cross the boundary

Props passed from a Server Component to a Client Component must be serialisable. This catches people out:

Can crossCannot cross
Strings, numbers, booleans, nullFunctions (except Server Actions)
Arrays and plain objectsClass instances
Date, Map, SetSymbols
PromisesJSX from a client import
Server ActionsAnything holding a closure

The class instance row bites hardest. An ORM model often looks like a plain object but carries methods, so passing it straight to a Client Component fails. Map it to a plain object first — which is good practice anyway, since it stops your database schema leaking into your UI props.

Server Actions

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'

export async function deletePost(formData: FormData) {
  const session = await auth()
  if (!session) throw new Error('Unauthorized')

  const id = String(formData.get('id'))
  const post = await db.post.findUnique({ where: { id } })

  if (post?.authorId !== session.userId) throw new Error('Forbidden')

  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

Note both checks. A Server Action is a public HTTP endpoint — anyone can call it with any arguments, regardless of what your UI shows. Authentication and authorisation belong inside the action, not in the component that renders the button. Hiding a delete button is not access control.

When not to use RSC

  • Highly interactive applications. An editor, a canvas tool, a live dashboard — if nearly everything is client state, RSC adds a boundary to reason about and gives little back.
  • Existing SPAs that work. Migrating a healthy client app to gain server rendering it does not need is a large project with a small payoff.
  • Content that is genuinely static. If nothing is personalised, a static site generator is simpler and cheaper.
  • When the team has not internalised the boundary. Without that, you get client components everywhere and all of the complexity with none of the benefit.

How to tell if it is working

Count 'use client' directives, and check where they sit in the tree:

grep -rl "use client" app/ | wc -l
grep -rn "use client" app/ | head -20

A healthy application has few of them, near the leaves — a search box, a menu, a chart. If a top-level layout or page carries one, everything beneath it is client code and you have lost the benefit without noticing.

Then check the network tab. If your JavaScript bundle looks like a normal React app's, RSC is not doing anything for you.

Frequently asked questions

What does "use client" actually do?

It marks an entry point into client code, not a single component. Everything imported below it becomes part of the client bundle too, which is why placing it high in the tree quietly turns an entire page into a client application.

Can a Client Component render a Server Component?

Yes, if the Server Component is passed as children or another prop. It cannot import one. That distinction is what makes the composition pattern work and keeps the boundary shallow.

Why do I get a serialisation error passing props?

Only serialisable values cross the boundary. Functions, class instances and symbols cannot. ORM models are the usual culprit — they look like plain objects but carry methods, so map them to plain objects first.

How do I avoid data fetching waterfalls in RSC?

Wrap independent requests in Promise.all rather than awaiting them in sequence. Sequential awaits read naturally and look fine against a fast local database, then add up badly in production.

Are Server Actions secure by default?

No. A Server Action is a public HTTP endpoint that anyone can call with any arguments. Authentication and authorisation must live inside the action itself — hiding the button that calls it is not access control.

Do Server Components replace API routes?

For your own UI's data, largely yes — you can query directly in the component. You still need API routes for third-party consumers, webhooks and mobile clients.

Should I migrate my existing React app to RSC?

Only if you have a concrete reason: a large bundle, poor first-load performance, or SEO requirements a client app cannot meet. Migrating a healthy SPA for its own sake is a big project with a small return.

How do I know if RSC is actually helping?

Count your 'use client' directives and check where they sit. Few, near the leaves, is healthy. One in a top-level layout means everything below it is client code — and your bundle will confirm it.

Share this article:
ES
Written by

Edrees Salih

Full-stack software engineer with 9 years of experience. Passionate about building scalable solutions and sharing knowledge with the developer community.

View Profile

Comments (0)

Leave a Comment

Your email will not be published.

No comments yet. Be the first to share your thoughts!

Related Articles

Related Articles

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.