Took me six months to actually understand RSC. Not the “I read the docs” understanding - the visceral “oh THAT’S why” moment that only comes from shipping code.
The docs make it sound simple: server components render on the server, client components run in the browser. Cool. Useless explanation.
Here’s what actually clicked for me.
The mental model that stuck
Stop thinking about server vs client components. Think about where the data lives.
Your component needs data from a database? Server component. Your component needs to respond to a click? Client component. That’s it. That’s the heuristic.
Everything else is just implementation details that’ll trip you up if you start there.
The real code
Here’s a pattern we use everywhere now:
// app/dashboard/page.tsx - Server Component (default)
async function DashboardPage() {
// This is just a regular async function
const stats = await db.query.userStats.findMany({
where: eq(schema.userStats.userId, session.userId)
});
return (
<div>
<StatsGrid data={stats} /> {/* Server Component */}
<ChartContainer data={stats} /> {/* Client Component */}
</div>
);
}
The StatsGrid can stay on the server because it’s just rendering HTML. The ChartContainer needs to be a client component because it uses a charting library with interactivity.
// components/ChartContainer.tsx
'use client';
import { LineChart } from 'recharts';
export function ChartContainer({ data }: Props) {
const [selectedMetric, setSelectedMetric] = useState('revenue');
return (
<div>
<MetricSelector value={selectedMetric} onChange={setSelectedMetric} />
<LineChart data={data} />
</div>
);
}
Notice: we’re not fetching data in the client component. We’re just rendering it. The data comes from the server, flows down as props.
Where it gets weird
The confusing part is the boundaries. You can’t import a server component into a client component directly. This breaks:
'use client';
import { UserProfile } from './UserProfile'; // Server component
export function Sidebar() {
return <UserProfile />; // ❌ Doesn't work
}
But this works:
// Server component
import { Sidebar } from './Sidebar';
import { UserProfile } from './UserProfile';
export function Layout() {
return (
<Sidebar>
<UserProfile /> {/* ✅ Works */}
</Sidebar>
);
}
// Client component
'use client';
export function Sidebar({ children }: { children: React.ReactNode }) {
return <div className="sidebar">{children}</div>;
}
See the difference? The server component (UserProfile) is passed as children. The client component just renders it. It’s a slot, not an import.
This pattern shows up everywhere once you know to look for it.
The performance win that actually matters
Everyone talks about bundle size. Yeah, smaller bundles are nice. But the real win is eliminating waterfalls.
Old pattern (client-side):
1. Load page JS bundle (1.2s)
2. React hydrates (200ms)
3. useEffect fires
4. Fetch user data (300ms)
5. Fetch related posts (250ms)
6. Render complete
Total: ~2s
With RSC:
1. Server fetches both queries in parallel (200ms)
2. Streams HTML to client
3. Client renders instantly
Total: ~200ms
We cut our dashboard load time from 2.1s to 600ms. Not by optimizing code - just by fetching data on the server where it’s closer to the database.
The gotchas that actually hurt
useState in server components
Tried to use useState in a server component? Won’t work. Server components run once. No re-renders. If you need state, that component needs to be a client component.
Context providers
Context providers are always client components. So your entire app/layout.tsx becomes a client component if you add a provider there. Solution: create a separate Providers client component and compose it in your server component layout.
// app/layout.tsx - Server Component
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// app/providers.tsx - Client Component
'use client';
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
Serialization limits You can’t pass functions as props from server to client components. Only serializable data. Had a bug where we tried to pass an onClick handler from a server component. TypeScript didn’t catch it, runtime did.
When to actually use this
Not every app needs RSC. We rebuilt our marketing site with it - huge win. Our internal admin tool? Still using regular React. The admin tool has too much interactivity and not enough database queries to benefit.
Use RSC when:
- You have data that changes frequently and you’re tired of cache invalidation
- Your app is mostly content display with some interactive elements
- SEO matters and you need real server-side rendering
Skip it when:
- You’re building a highly interactive app (like Figma or Notion)
- Your team is still learning React fundamentals
- You don’t have server infrastructure
The actual learning curve
Took our team about 3 weeks to stop fighting the framework. First week we kept reaching for useEffect to fetch data. Second week we kept putting 'use client' everywhere. Third week it clicked.
Now we default to server components and only go client when we need interactivity. Code is cleaner, faster, and we spend less time thinking about loading states because the data is just… there.
Still feels like magic sometimes. Good magic though.