We adopted React Server Components expecting a fairly simple win:
Less client JavaScript
↓
More server-side data fetching
↓
Faster pages
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
With Server Components, the architecture became closer to:
Server Components
↓
RSC Payload
↓
Client Components
↓
Browser interaction
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>
);
}
Server Components cannot maintain client-side state or register browser event handlers, so the component needed:
"use client";
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
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
we moved the boundary downward:
Dashboard Server
├── Header Server
├── Report Server
└── Filters Client
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
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.
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
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}`;
}
}
Then we tried:
<ProductPrice price={new Money(99, "USD")} />
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")
we passed DTO-style data:
{
amount: 99,
currency: "USD"
}
Then the client handled presentation.
This pushed us toward a cleaner rule:
Server domain model
↓
Serializable view model
↓
Client component
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");
}, []);
we could write:
async function Orders() {
const orders = await getOrders();
return <OrderList orders={orders} />;
}
Much nicer.
Then we accidentally wrote pages like this:
const user = await getUser();
const orders = await getOrders();
const recommendations = await getRecommendations();
If those requests were independent, we had created a waterfall.
getUser()
↓
wait
↓
getOrders()
↓
wait
↓
getRecommendations()
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()
]);
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
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
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?
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
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
}
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 }
});
}
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 }
});
}
Lesson
"use server" tells React where code executes.
It does not mean:
trusted
authorized
validated
safe
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
After RSC, an error might occur during:
server rendering
database access
RSC serialization
Server Function execution
stream generation
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
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} />;
}
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
Client Components
Test:
interaction
state
browser behavior
accessibility
Integration
Test:
Server Component
↓
Client boundary
↓
mutation
↓
updated UI
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
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
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
After:
Server
│
┌──────────┴──────────┐
▼ ▼
Server Components Data Layer
│ │
└──────────┬──────────┘
▼
RSC Payload
│
▼
Client Islands
│
▼
User Interaction
│
▼
Server Functions
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
Second: Map Server-Only Data
Identify:
database access
secrets
internal APIs
authorization
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
The better model was:
React
+
two execution environments
+
a network boundary
+
serialization
+
server data access
+
client interactivity
+
new security responsibilities
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?
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)