DEV Community

Samcorp
Samcorp

Posted on

What Broke When We Adopted React Server Components

We adopted React Server Components expecting a fairly simple win:

What Broke When We Adopted React Server Components

Less client JavaScript
        ↓
More server-side data fetching
        ↓
Faster pages
Enter fullscreen mode Exit fullscreen mode

Some of that happened.

But the migration also broke assumptions that had quietly existed across our frontend for years.

Components that used to be interchangeable suddenly belonged to different environments. Shared objects could no longer always cross component boundaries. Data fetching moved closer to the database but sometimes became slower because we created waterfalls.

The biggest lesson from our React Server Components issues was simple:

RSC was not just a rendering optimization. It changed the architecture of the application.

Note: This is a representative engineering post-mortem. The examples describe common RSC migration problems rather than a specific customer deployment.


What We Expected

Our previous mental model was straightforward:

Server
  ↓
HTML + JavaScript
  ↓
React in the browser
  ↓
API calls
Enter fullscreen mode Exit fullscreen mode

With Server Components, the architecture became closer to:

Server Components
       ↓
RSC Payload
       ↓
Client Components
       ↓
Browser interaction
Enter fullscreen mode Exit fullscreen mode

The benefit was obvious.

Code that only needed to run on the server no longer needed to become browser JavaScript.

But we underestimated how important the boundary between those two environments would become.


Break #1: "use client" Spread Further Than Expected

Our first problem looked harmless.

A component needed state:

import { useState } from "react";

export default function Filters() {
  const [open, setOpen] = useState(false);

  return (
    <button onClick={() => setOpen(!open)}>
      Filters
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Components cannot maintain client-side state or register browser event handlers, so the component needed:

"use client";
Enter fullscreen mode Exit fullscreen mode

That part was expected.

What surprised us was what happened to its dependencies.

Once a module becomes part of the client component graph, code imported beneath that boundary can also become client-side code.

So this:

Page
 ↓
Dashboard
 ↓
Filters
 ↓
Utilities
 ↓
Formatting library
Enter fullscreen mode Exit fullscreen mode

could accidentally turn more code into browser JavaScript than we intended.

React's documentation explicitly describes "use client" as a boundary in the module dependency tree, not merely a marker for one component.

This is where scalable web development becomes less about component count and more about defining clean boundaries between server-rendered logic and client-side interactivity.

What We Changed

We stopped placing "use client" high in the tree.

Instead of:

Dashboard
  "use client"
      ↓
Everything below it
Enter fullscreen mode Exit fullscreen mode

we moved the boundary downward:

Dashboard        Server
├── Header       Server
├── Report       Server
└── Filters      Client
Enter fullscreen mode Exit fullscreen mode

The rule became:

Keep the interactive island as small as practical.


Break #2: Browser Assumptions Started Failing

Years of React development had left browser assumptions everywhere.

Components accessed:

window
document
localStorage
navigator
Enter fullscreen mode Exit fullscreen mode

Some third-party libraries also expected a browser environment immediately during module evaluation.

Then those components moved into server-rendered code.

Suddenly we saw errors around unavailable browser APIs.

The solution was not:

Add "use client" everywhere.
Enter fullscreen mode Exit fullscreen mode

That would have removed much of the reason for adopting Server Components.

Instead, we separated concerns.

For example:

ProductPage
   │
   ├── ProductDetails      Server
   ├── Recommendations    Server
   └── RecentlyViewed     Client
Enter fullscreen mode Exit fullscreen mode

Only the feature that genuinely needed localStorage remained client-side.

Lesson

A component needing browser APIs is not a failure.

But it needs an explicit client boundary.

React's current documentation makes the same distinction: Server Components cannot use interactive browser behavior or most stateful Hooks, while Client Components handle that work.


Break #3: Our Shared Objects Stopped Crossing the Boundary

Before RSC, we passed rich JavaScript objects around freely.

For example:

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  formatted() {
    return `${this.currency} ${this.amount}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we tried:

<ProductPrice price={new Money(99, "USD")} />
Enter fullscreen mode Exit fullscreen mode

across a server-to-client boundary.

That exposed another rule.

Values passed from Server Components into Client Components need to be serializable.

Plain data works well.

Custom class instances do not.

React's documented serializable types include primitives, arrays, maps, sets, dates, plain objects, and several other supported values—but ordinary class instances are not supported across that boundary.

What We Changed

Instead of passing domain objects:

new Money(99, "USD")
Enter fullscreen mode Exit fullscreen mode

we passed DTO-style data:

{
  amount: 99,
  currency: "USD"
}
Enter fullscreen mode Exit fullscreen mode

Then the client handled presentation.

This pushed us toward a cleaner rule:

Server domain model
      ↓
Serializable view model
      ↓
Client component
Enter fullscreen mode Exit fullscreen mode

It was annoying at first.

Architecturally, it ended up being healthier.


Break #4: Moving Data Fetching to the Server Created Waterfalls

One reason we adopted Server Components was cleaner data fetching.

Instead of:

useEffect(() => {
  fetch("/api/orders");
}, []);
Enter fullscreen mode Exit fullscreen mode

we could write:

async function Orders() {
  const orders = await getOrders();

  return <OrderList orders={orders} />;
}
Enter fullscreen mode Exit fullscreen mode

Much nicer.

Then we accidentally wrote pages like this:

const user = await getUser();
const orders = await getOrders();
const recommendations = await getRecommendations();
Enter fullscreen mode Exit fullscreen mode

If those requests were independent, we had created a waterfall.

getUser()
   ↓
wait
   ↓
getOrders()
   ↓
wait
   ↓
getRecommendations()
Enter fullscreen mode Exit fullscreen mode

The code looked clean.

The request was slow.

Next.js documentation explicitly warns about accidental sequential data fetching and recommends parallelizing independent work when possible.

The Fix

Where requests were independent:

const [
  user,
  orders,
  recommendations
] = await Promise.all([
  getUser(),
  getOrders(),
  getRecommendations()
]);
Enter fullscreen mode Exit fullscreen mode

And where parts of the page could arrive later, we used Suspense and streaming rather than blocking the whole route.

Page shell
   ↓
Important content
   ↓
──────── streamed later ────────
   ↓
Recommendations
Enter fullscreen mode Exit fullscreen mode

Streaming lets slower parts of a route arrive separately instead of blocking everything above them.

Lesson

Server-side data fetching does not automatically mean fast data fetching.

The dependency graph still matters.


Break #5: Caching Became Part of Application Correctness

This issue was framework-specific rather than an RSC rule itself.

Once more rendering happened on the server, our framework's caching and revalidation behavior suddenly mattered much more.

Previously, developers often thought:

Call API
   ↓
Receive current data
Enter fullscreen mode Exit fullscreen mode

Now we needed to ask:

Was this rendered dynamically?

Was the result cached?

When does it expire?

What invalidates it?

Should this page be static at all?
Enter fullscreen mode Exit fullscreen mode

The bugs were frustrating because stale data often looked valid.

A product page displaying yesterday's inventory does not throw an exception.

It simply lies quietly.

What We Changed

Every important data source received an explicit caching decision:

Product description
→ cache aggressively

Current inventory
→ fresher policy

User session
→ request-specific

Pricing
→ explicit business TTL / invalidation
Enter fullscreen mode Exit fullscreen mode

We stopped treating caching as a performance detail.

It became part of data correctness.


Break #6: Server Functions Looked Safer Than They Actually Were

Server Functions made mutations elegant.

Conceptually:

"use server";

export async function updateOrder(orderId) {
  // update order
}
Enter fullscreen mode Exit fullscreen mode

Then a Client Component could invoke that function without manually building another traditional API route.

The abstraction felt safe because the function lived on the server.

That is not enough.

React's documentation explicitly warns that arguments sent to Server Functions are fully client-controlled and that authorization must be performed inside the server-side operation.

This is wrong:

"use server";

export async function deleteInvoice(invoiceId) {
  await db.invoice.delete({
    where: { id: invoiceId }
  });
}
Enter fullscreen mode Exit fullscreen mode

A better pattern is closer to:

"use server";

export async function deleteInvoice(invoiceId) {
  const user = await requireUser();

  const invoice = await db.invoice.findUnique({
    where: { id: invoiceId }
  });

  if (!canDeleteInvoice(user, invoice)) {
    throw new Error("Unauthorized");
  }

  await db.invoice.delete({
    where: { id: invoiceId }
  });
}
Enter fullscreen mode Exit fullscreen mode

Lesson

"use server" tells React where code executes.

It does not mean:

trusted
authorized
validated
safe
Enter fullscreen mode Exit fullscreen mode

Those are still our responsibilities.


Break #7: Debugging Moved Away From the Browser

Before the migration, developers were used to finding most problems in:

Browser DevTools
Console
Network tab
Client stack trace
Enter fullscreen mode Exit fullscreen mode

After RSC, an error might occur during:

server rendering
database access
RSC serialization
Server Function execution
stream generation
Enter fullscreen mode Exit fullscreen mode

The browser sometimes only showed the result of that failure.

The useful stack trace lived on the server.

That changed our observability requirements.

We added clearer logging around:

Route
Request ID
User/session context
Server component
Data fetch
Server Function
Duration
Failure
Enter fullscreen mode Exit fullscreen mode

Our mental model changed from:

This is a frontend application.

to:

This is a distributed full-stack application that happens to use React.


Break #8: Our Tests Were Testing the Wrong Architecture

We had many tests built around client-rendered React.

They assumed components could:

  • Use browser APIs
  • Call mocked REST endpoints
  • Run entirely inside a DOM test environment

Server Components changed those assumptions.

A component like:

async function AccountPage() {
  const account = await db.account.findFirst();

  return <Account account={account} />;
}
Enter fullscreen mode Exit fullscreen mode

is no longer just a UI function.

It touches a server-side dependency.

So we separated testing into layers.

Server Logic

Test:

queries
authorization
transformations
Server Functions
Enter fullscreen mode Exit fullscreen mode

Client Components

Test:

interaction
state
browser behavior
accessibility
Enter fullscreen mode Exit fullscreen mode

Integration

Test:

Server Component
      ↓
Client boundary
      ↓
mutation
      ↓
updated UI
Enter fullscreen mode Exit fullscreen mode

Trying to force all three into the old frontend test strategy created unnecessary pain.

We also started treating the migration as a broader software testing and QA problem, with separate coverage for server logic, client interaction, integration behavior, performance, and regression risk.


One More Lesson: RSC Added a New Security Patch Surface

There was another operational lesson we could not ignore.

React disclosed a critical React Server Components vulnerability in December 2025, followed by additional denial-of-service and source-exposure issues. React recommended immediate upgrades to patched packages.

That changed our dependency policy.

RSC infrastructure was no longer something we could treat like passive frontend tooling.

It was server infrastructure exposed to requests.

We added:

React/RSC security advisories
       ↓
Dependency review
       ↓
Framework compatibility check
       ↓
Rapid patch rollout
Enter fullscreen mode Exit fullscreen mode

The broader lesson was not that Server Components are inherently unsafe.

It was:

Once React becomes part of your server execution path, React security updates become production server updates.


What Actually Worked

After the rough migration, several rules made the architecture much easier to reason about.

1. Server by Default

Components stayed server-side unless they genuinely required:

state
effects
events
browser APIs
Enter fullscreen mode Exit fullscreen mode

2. Small Client Islands

We pushed "use client" as far down the component tree as practical.

3. Serializable Boundaries

Server Components returned simple, intentional view models to Client Components.

4. Parallel Data Fetching

Independent server requests started together.

5. Suspense Around Slow Work

Slow data stopped blocking unrelated UI.

6. Explicit Cache Decisions

Every important data source had a freshness policy.

7. Authorization Inside Server Functions

Never at the button.

Never only in the UI.

8. Server Observability

Logs and tracing became part of frontend debugging.


The Architecture We Ended Up With

Before:

React Client
     ↓
API
     ↓
Backend
     ↓
Database
Enter fullscreen mode Exit fullscreen mode

After:

                 Server
                   │
        ┌──────────┴──────────┐
        ▼                     ▼
 Server Components       Data Layer
        │                     │
        └──────────┬──────────┘
                   ▼
              RSC Payload
                   │
                   ▼
            Client Islands
                   │
                   ▼
            User Interaction
                   │
                   ▼
           Server Functions
Enter fullscreen mode Exit fullscreen mode

Neither architecture is automatically better.

But the second requires much more intentional boundary design.


What We Would Do Differently

If we adopted RSC again, we would start with boundaries rather than components.

First: Map Interactive Areas

Identify everything requiring:

useState
useEffect
events
browser APIs
Enter fullscreen mode Exit fullscreen mode

Second: Map Server-Only Data

Identify:

database access
secrets
internal APIs
authorization
Enter fullscreen mode Exit fullscreen mode

Third: Define the Boundary

Decide what data is allowed to cross from server to client.

Fourth: Inspect Data Dependencies

Find sequential fetches before they become waterfalls.

Fifth: Define Cache Rules

Do not wait for stale-data bugs.

Sixth: Rebuild the Test Strategy

Treat server logic and browser interaction as different layers.

Seventh: Add Server Observability

Before production traffic arrives.


The Biggest Lesson

The biggest mistake was treating React Server Components as:

React
+
better server rendering
Enter fullscreen mode Exit fullscreen mode

The better model was:

React
+
two execution environments
+
a network boundary
+
serialization
+
server data access
+
client interactivity
+
new security responsibilities
Enter fullscreen mode Exit fullscreen mode

That explains most of the React Server Components issues we encountered.

The code still looked like React.

The architecture was no longer purely frontend.


Final Takeaway

React Server Components can be valuable.

They can reduce client-side JavaScript, keep server-only logic off the browser, and make server data access feel natural.

But adopting them successfully requires asking a different question.

Not:

Which components should we convert to Server Components?

Ask:

Where should this code execute?

Which code really needs the browser?

What data is allowed to cross the boundary?

Which requests can run in parallel?

What must stay fresh?

Where is authorization enforced?

Where will failures appear?
Enter fullscreen mode Exit fullscreen mode

Once we started answering those questions explicitly, the migration became much easier.

The biggest lesson was simple:

React Server Components are not just a component feature. They are an application-boundary decision.

Top comments (0)