React Server Components (RSC) are the default in Next.js App Router. But "default" doesn't mean "always." Here's the decision framework I use after building 4 production apps with RSC.
The one-sentence rule
If a component needs interactivity (clicks, state, effects) or browser APIs, make it a client component. Everything else stays server.
That's it. The nuance is in knowing what "needs interactivity" actually means.
Server Components: what they actually do
Server Components render on the server and send HTML to the client. No JavaScript ships for them. They can:
// app/dashboard/page.tsx — Server Component (default)
import { db } from '@/lib/db';
export default async function Dashboard() {
// Direct database access — no API route needed
const stats = await db.analytics.getStats();
const recentOrders = await db.orders.findMany({ take: 10 });
return (
<div>
<h1>Dashboard</h1>
<StatsGrid stats={stats} />
<OrderTable orders={recentOrders} />
</div>
);
}
No useEffect. No useState. No loading states. The data is fetched at render time, on the server, and the complete HTML arrives at the client.
The performance implication: StatsGrid and OrderTable ship zero JavaScript if they're also Server Components. A dashboard with 20 components might ship 0KB of component JavaScript.
Client Components: when you need them
Add 'use client' at the top when a component needs:
'use client';
// 1. Event handlers
<button onClick={handleClick}>
// 2. State
const [count, setCount] = useState(0);
// 3. Effects
useEffect(() => { ... }, []);
// 4. Browser APIs
window.localStorage, navigator, document
// 5. Custom hooks that use any of the above
const { data } = useSWR('/api/data');
// 6. Third-party libraries that use hooks
import { motion } from 'framer-motion';
The patterns that work
Pattern 1: Server wrapper, client island
// app/products/page.tsx — Server Component
import { db } from '@/lib/db';
import { ProductFilter } from './ProductFilter'; // Client Component
export default async function ProductsPage() {
const products = await db.products.findMany();
const categories = await db.categories.findMany();
return (
<div>
<h1>Products</h1>
{/* Client island: handles filter state + interactivity */}
<ProductFilter
initialProducts={products}
categories={categories}
/>
</div>
);
}
// app/products/ProductFilter.tsx
'use client';
import { useState } from 'react';
export function ProductFilter({ initialProducts, categories }) {
const [filter, setFilter] = useState('all');
const filtered = filter === 'all'
? initialProducts
: initialProducts.filter(p => p.category === filter);
return (
<>
<select onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<ProductGrid products={filtered} />
</>
);
}
The server fetches the data. The client handles the filtering. No loading spinner. No API call for the initial data.
Pattern 2: Composition over client boundaries
// WRONG: Making the entire layout a client component
'use client';
export function Layout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div>
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<main>{children}</main> {/* All children become client components */}
</div>
);
}
// RIGHT: Only the toggle button is a client component
// layout.tsx (Server Component)
export function Layout({ children }) {
return (
<div>
<Sidebar />
<main>{children}</main>
</div>
);
}
// Sidebar.tsx (Server Component)
function Sidebar() {
return (
<nav>
<SidebarToggle /> {/* Only this is 'use client' */}
<NavLinks /> {/* Stays server — no JS shipped */}
</nav>
);
}
Push 'use client' as deep as possible. The higher it is, the more components become client components.
Pattern 3: Server Actions for mutations
// Server Component with a Server Action
export default function SettingsPage() {
async function updateProfile(formData: FormData) {
'use server';
const name = formData.get('name') as string;
await db.user.update({ where: { id: userId }, data: { name } });
revalidatePath('/settings');
}
return (
<form action={updateProfile}>
<input name="name" defaultValue={currentUser.name} />
<button type="submit">Save</button>
</form>
);
}
No API route. No fetch call. The form submits directly to the server function. Works without JavaScript (progressive enhancement).
When to NOT use Server Components
Real-time UIs: Chat, live dashboards, collaborative editing — these need WebSocket connections and constant state updates. Client components with useSWR or similar.
Heavy animation: Framer Motion, react-spring, GSAP — all require client-side rendering. The animated component and its parent need 'use client'.
Form-heavy pages: Complex multi-step forms with validation, conditional fields, and real-time feedback are better as client components with React Hook Form.
Third-party widgets: Payment forms (Stripe Elements), maps (Google Maps), rich text editors — all require browser APIs and are inherently client-side.
The performance reality
A page with 50 Server Components and 3 Client Components ships JavaScript for 3 components. The other 47 are pure HTML.
Compare to a traditional SPA where all 50 components ship JavaScript, even the ones that never change after initial render.
For content-heavy pages (dashboards, documentation, marketing sites, e-commerce listings), the difference is 10-50KB vs 200-500KB of JavaScript. That's a real performance win, especially on mobile.
For interaction-heavy pages (editors, tools, games), the difference is marginal because most components are client components anyway.
The decision tree
Does this component need onClick, onChange, or any event handler?
→ Yes → Client Component
Does it use useState, useEffect, useRef, or any hook?
→ Yes → Client Component
Does it use a browser API (window, document, navigator)?
→ Yes → Client Component
Does it import a library that uses hooks internally?
→ Yes → Client Component
None of the above?
→ Server Component (default, no annotation needed)
The AI SaaS Starter Kit follows this exact pattern — server components for data-heavy pages, client islands for interactive elements, server actions for mutations. The result is a dashboard that loads in under 1 second with full interactivity.