finally wrapping my head around React Server Components and honestly it's less about new syntax and more about a completely different mental model.
The Old Mental Model
everything runs on client → fetch data → render → interactivity
'use client';
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []);
return <DataList data={data} />;
problems: loading states, client-side waterfall, large JS bundle, potential race conditions.
The RSC Mental Model
components can run on server → fetch data there → send HTML to client → add interactivity only where needed
// this runs on server - no client JS needed
async function DataList() {
const data = await db.query('SELECT * FROM products');
return <ProductGrid products={data} />;
}
no useEffect. no loading state. no client-side data fetching. just... data and JSX.
The Key Insight
think about where the code runs, not just what it renders.
| Concern | Server Components | Client Components | |---------|-------------------|-------------------| | Data fetching | directly from DB | via API | | Bundle size | 0 KB | their JS size | | Interactivity | none (static) | event handlers, state | | Access to browser | none | full access |
When to Use What
Server Component (default):
// page.tsx - fetch data, render UI
async function Page() {
const data = await getData();
return <Layout>{data.map(item => <Item key={item.id} {...item} />)}</Layout>;
}
Client Component (only when needed):
'use client';
// search.tsx - needs interactivity
'use client';
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
);
The Pattern I Use
- Start with Server Components
- Only add 'use client' when:
- Need useState/useEffect
- Need browser APIs
- Need event handlers
- Using client-side libraries
Common Mistakes
❌ Making entire page 'use client' ❌ Fetching data in Client Component when you could in Server ❌ Not understanding client vs server boundary
✅ Keep client boundaries small ✅ Push data fetching to server ✅ Add interactivity only where needed
Real Example
// page.tsx (server) - fetch user data
async function ProfilePage({ params }) {
const user = await getUser(params.id);
return (
<div>
<ProfileHeader user={user} /> {/* server */}
<FollowButton userId={user.id} /> {/* client - needs onClick */}
</div>
);
}
// FollowButton.tsx (client) - only interactive part
'use client';
export function FollowButton({ userId }) {
const [following, setFollowing] = useState(false);
return (
<button onClick={() => setFollowing(!following)}>
{following ? 'Following' : 'Follow'}
</button>
);
}
Benefits I've Noticed
- way smaller JS bundles
- no loading spinners for initial data
- SEO actually works (content is HTML)
- feels faster to users
The Learning Curve
ngl, initially confusing. took me a few projects to get comfortable. but once it clicks, you can't go back.
resources that helped:
- Next.js docs (actually good)
- Building a real project with it
- thinking in server/client boundaries
default to server. add client only when needed. this single rule changed how I architect apps.
Related Articles
InertiaJS + React: The Best of Both Worlds
What is InertiaJS and how to integrate it with React in a Laravel app. Installation, routing, performance tips, and debugging explained.
Ant Design + Zod = Dynamic Forms That Don't Suck
How I built flexible, validated dynamic forms for enterprise ERP systems using Ant Design and Zod.