React Server Components (RSC) changed how I think about building web apps. But the learning curve was brutal. I spent 3 weeks debugging a hydration error that turned out to be a single useEffect in a server component. Here is everything I learned so you don’t make the same mistakes.
What Are Server Components, Really?
Forget the marketing. Server components are React components that run on the server and send HTML to the client. They can access databases, read files, and use any Node.js API. They don’t ship JavaScript to the browser.
Client components are the React you know — they run in the browser, handle state, and respond to user interactions. They ship JavaScript.
The key insight: server components can import client components, but client components cannot import server components. This one rule caused me the most confusion.
Project 1: The E-Commerce Store (Next.js 14)
My first RSC project was an e-commerce site with 200+ products. Here is the architecture:
The Good
Product pages loaded instantly because the product data was fetched on the server. No loading spinners, no client-side fetching. Just HTML.
// app/products/[id]/page.tsx — Server Component
export default async function ProductPage({ params }) {
const product = await db.product.findUnique({
where: { id: params.id }
});
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<ProductGallery images={product.images} />
<AddToCartButton productId={product.id} />
</div>
);
}
The ProductGallery is a client component (it has image zoom and swipe). The AddToCartButton is also a client component (it manages cart state). But the page itself is a server component — zero JavaScript for the product data.
The Bad
I tried to use a context provider for the shopping cart at the layout level. It broke because layouts are server components by default. The fix: wrap the children in a client component that provides the context.
// app/providers.tsx — Client Component
'use client';
import { CartProvider } from '@/context/cart';
export function Providers({ children }) {
return <CartProvider>{children}</CartProvider>;
}
// app/layout.tsx — Server Component
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
The Ugly
The search feature. I wanted real-time search with debouncing. Server components can’t handle user input. Client components can’t fetch from the database directly. I ended up with:
- Server component fetches initial results
- Client component handles input and debouncing
- API route (server) processes the search query
- Client component displays results
It works, but it’s more complex than a pure client-side search would be.
Project 2: The Dashboard App
A SaaS dashboard with charts, tables, and real-time data. This is where RSC really shines.
Server Components for Data Fetching
Every dashboard widget is a server component that fetches its own data. No more useEffect chains, no loading states for each widget.
// components/RevenueChart.tsx — Server Component
export async function RevenueChart({ dateRange }) {
const revenue = await db.orders.aggregate({
where: { date: dateRange },
_sum: { amount: true },
_count: true,
});
return <Chart data={revenue} />
}
The chart component fetches data, renders HTML, and sends it to the client. The browser receives a fully rendered chart — no JavaScript needed for the initial render.
Streaming for Slow Queries
Some queries take 2-3 seconds. With React’s Suspense, I can stream the fast parts first and fill in the slow parts later:
export default function Dashboard() {
return (
<div>
<QuickStats /> {/* Renders immediately */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart dateRange="30d" /> {/* Streams when ready */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* Streams when ready */}
</Suspense>
</div>
);
}
This is the biggest win of RSC. Users see content immediately instead of waiting for everything to load.
Project 3: The Blog (This Site)
Yes, this blog uses RSC. Every article page is a server component. The markdown is converted to HTML on the server. The browser receives fully rendered HTML — perfect for SEO.
When NOT to Use Server Components
After 3 projects, here is when I avoid RSC:
- Real-time apps: Chat, live collaboration — use client components with WebSockets
- Heavy interactivity: Drag-and-drop, canvas, complex animations — client components
- Small projects: A simple landing page doesn’t need RSC complexity
- When your team doesn’t know React well: RSC adds mental overhead
My Rules for RSC
- Default to server components. Only use client components when you need interactivity.
- Push client components to the leaves. Keep the tree server-side as long as possible.
- Never put
‘use client’in a file that also does data fetching. Split them. - Use
Suspensefor anything slow. Streaming is free and users love it. - Test without JavaScript. Disable JS in your browser. If the page still works, you’re doing RSC right.
Performance Numbers
Here are the actual numbers from my projects:
| Metric | Before RSC | After RSC |
|---|---|---|
| Initial JS bundle | 340 KB | 85 KB |
| First Contentful Paint | 2.1s | 0.8s |
| Time to Interactive | 3.4s | 1.2s |
| Lighthouse Score | 72 | 96 |
Final Thoughts
RSC is not a silver bullet. It’s a tool that, when used correctly, makes your app faster and your code simpler. But it requires a shift in thinking. Stop thinking “fetch data in useEffect.” Start thinking “fetch data in the component.”
The hardest part is unlearning old habits. The easiest part is seeing your Lighthouse score jump from 72 to 96.