Server Components vs Client Components in Next.js 15
Next.js 15 (App Router) defaults to Server Components. They run on the server, never ship JavaScript to the browser, and can talk directly to your database. Client Components opt in with 'use client' and run in the browser like classic React.
Server Components — the default
// app/products/page.tsx — no 'use client'
import { db } from '@/lib/db';
export default async function ProductsPage() {
const products = await db.product.findMany(); // direct DB call, server-side
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
What you get:
- Zero JS for this component sent to the browser
- Direct access to DB, file system, env secrets
asynccomponents are first-class- Faster initial load, better SEO
What you LOSE:
useState,useEffect,useRef, all hooks- Browser APIs (
window,localStorage) - Event handlers like
onClick(you can pass them down, but they must be in client components)
Client Components — for interactivity
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The 'use client' directive tells Next.js: "this file (and what it imports) ships to the browser."
Composition pattern — mix them
// app/post/[slug]/page.tsx — Server Component
import { db } from '@/lib/db';
import { LikeButton } from './like-button'; // client component
export default async function Post({ params }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton postId={post.id} initialCount={post.likes} />
</article>
);
}
// like-button.tsx — Client Component
'use client';
import { useState } from 'react';
export function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => { setCount(count + 1); fetch('/api/like', { method: 'POST', body: JSON.stringify({ postId }) }); }}>
❤️ {count}
</button>
);
}
The page is mostly static HTML; only the LikeButton ships as JS.
Server Component can render Client Component
// ✅ allowed
<ServerComponent>
<ClientComponent />
</ServerComponent>
Client Component CANNOT import Server Component directly
'use client';
import { ServerThing } from './server-thing'; // ❌ — server code in client bundle
Instead, pass a server component as children prop:
'use client';
export function Wrapper({ children }) {
return <div className="wrapper">{children}</div>;
}
// Parent server component
<Wrapper>
<ServerThing />
</Wrapper>
Pass props from Server to Client
Props must be JSON-serializable — strings, numbers, plain objects, arrays. NOT functions, classes, Date (use ISO strings), Map, Set.
// Server
<LikeButton postId={post.id} initialCount={post.likes} /> // ✅
// ❌ Function prop crosses boundary
<LikeButton onLike={() => updateDB()} />
Instead, use server actions:
'use server';
export async function incrementLikes(postId: string) {
await db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
}
'use client';
import { incrementLikes } from './actions';
export function LikeButton({ postId }) {
return <button onClick={() => incrementLikes(postId)}>Like</button>;
}
When to use which
| Use Server Component | Use Client Component |
|---|---|
| Fetch data (DB, API, file system) | Use useState/useEffect/hooks |
| Use secrets / env vars | Handle onClick, onChange, etc. |
| Static or semi-static content | Browser APIs (window, localStorage) |
| SEO-critical content | Subscribe to external stores (zustand, redux) |
| Reduce bundle size | Use refs, focus management |
Default rule
Make it a Server Component unless you need something only a Client Component can do.
The smaller the client bundle, the faster the site. Move 'use client' as deep as possible in the tree.
Hydration trap
'use client';
function Time() {
return <p>{new Date().toLocaleString()}</p>;
}
Server renders one timestamp, client hydrates with a different one → hydration error. Either use useEffect to set the value after mount, or render the time only on the client with suppressHydrationWarning.