Server and client component patterns in Next.js
Server and client component patterns in Next.js split rendering work between Node.js and the browser, letting you ship less JavaScript while keeping interactivity exactly where the UI needs it.
Theory
TL;DR
- Server Components render on the server and send HTML - no JS bundle shipped for that file
- Client Components (marked
"use client") hydrate in the browser and handle state, events, browser APIs - Default to Server Components; add
"use client"only where hooks or DOM interaction are needed - Push Client Components as far down the tree as possible - a small leaf, not a whole page
- Server Components can wrap Client Components via
children; Client Components cannot import Server Components directly
Quick example
// app/page.tsx - Server Component, fetches data directly on the server
async function Page() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <PostList posts={posts} />; // Sends HTML, zero client JS for this file
}
// components/PostItem.tsx - Client Component, handles likes
'use client';
import { useState } from 'react';
function PostItem({ post }: { post: Post }) {
const [likes, setLikes] = useState(0);
return (
<li>
{post.title}
<button onClick={() => setLikes(likes + 1)}>👍 {likes}</button>
</li>
);
}
// Server renders the full list as HTML. Client hydrates only the like buttons.The server handles data fetching and initial HTML. The client takes over only at the button.
When to use each
- Fetch data or access DB: Server Component - direct access, no secrets exposed to the browser
- Static content, SEO: Server Component - pre-rendered HTML for crawlers
useState,useEffect, other hooks: Client Component - browser-only React features- Event handlers (
onClick,onChange): Client Component - needs the DOM - Browser APIs (
localStorage,window,document): Client Component - server has no browser context - Minimize JS bundle: Server Component - no hydration JS shipped
Comparison
| Aspect | Server Component | Client Component |
|---|---|---|
| Execution | Node.js (SSR/RSC) | Browser (hydration) |
| JS bundle | None shipped | Full JS + hydration |
| Data fetching | Direct async/await, DB | fetch(), SWR, TanStack Query |
| Interactivity | None | Full (state, events) |
| Children | Can include Client Components | Cannot import Server Components |
| Secrets | Safe in server env vars | Risky - exposed in bundle |
| Typical use | Dashboard with DB data, SEO blog | Forms, modals, toggle menus |
Pattern 1: Push Client components to the leaves
Move "use client" to the smallest possible piece of the UI. A blog post page is mostly static HTML - only the share button needs the browser.
// app/blog/[slug]/page.tsx - Server Component
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug); // Direct DB call, no API key exposed
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p> {/* Static HTML, no JS shipped */}
<ShareButton url={post.url} /> {/* Client boundary lives here */}
</article>
);
}
// components/ShareButton.tsx - Client Component
'use client';
export function ShareButton({ url }: { url: string }) {
return <button onClick={() => navigator.share({ url })}>Share</button>;
}Compare that to putting "use client" on the whole BlogPost page. You force a useEffect fetch cycle, ship the full component tree as JS, and lose server-rendered HTML for SEO. I've seen this mistake drop Lighthouse scores from 90+ to below 60 on content-heavy sites.
Pattern 2: Server Components as children
A Client Component can accept Server Components as children. The wrapper renders on the client; the children still run on the server. The key: pass them from a Server parent, not import them inside the Client Component.
// components/Accordion.tsx - Client Component
'use client';
import { useState } from 'react';
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
// app/page.tsx - Server Component
export default async function Page() {
const data = await fetchData(); // Runs on server
return (
<Accordion title="Details">
<ServerContent data={data} /> {/* Renders on server, passed as children */}
</Accordion>
);
}Shadcn/UI ships its interactive wrappers this way: Client shells that accept server-rendered content as slots.
Pattern 3: Fetch on server, interact on client
Fetch the full dataset in a Server Component and pass it as serializable props. Let the Client Component handle in-memory filtering, sorting, or pagination - no extra network round trips.
// app/products/page.tsx - Server Component
export default async function ProductsPage() {
const products = await getProducts(); // One server-side call
return <ProductGrid products={products} />;
}
// components/ProductGrid.tsx - Client Component
'use client';
export function ProductGrid({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
const filtered = products.filter(p => p.name.includes(filter));
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}Props crossing the server-client boundary must be serializable: strings, numbers, plain objects, arrays. Functions cannot cross that line - that is where Server Actions come in.
Pattern 4: Context Providers
React Context requires a Client Component. Wrap providers in a separate file and import them from a Server layout. The children remain server-rendered because they are passed in from outside the Client boundary.
// app/providers.tsx - Client Component
'use client';
import { ThemeProvider } from 'next-themes';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}
// app/layout.tsx - Server Component
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>
{children} {/* Still Server Components */}
</Providers>
</body>
</html>
);
}Common mistakes
Fetching data inside a Client Component when a Server parent would do:
// Bad - double fetch, bundle bloat, flash of empty content
'use client';
export function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts); }, []);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// Good - fetch in a Server parent, pass as props
export default async function Page() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <PostList posts={posts} />; // PostList just renders, no useEffect needed
}Adding "use client" to the root layout:
// Bad - forces the entire app to ship as client JS
// app/layout.tsx
'use client'; // Everything below now hydratesKeep root layouts as Server Components. Extract only the parts that need client features into their own files.
Passing a function as a prop from Server to Client:
// Bad - functions are not serializable across the RSC boundary
export default function Server() {
return <ClientComp onClick={() => console.log('hello')} />; // Runtime error
}
// Good - define the handler inside the Client Component
'use client';
export function ClientComp() {
return <button onClick={() => console.log('hello')}>Click</button>;
}For server-side mutations triggered by a client event, use a Server Action: mark the function 'use server' and attach it to a form action prop.
Using browser APIs in a Server Component:
// Bad - server has no window or localStorage
export default function Theme() {
localStorage.setItem('theme', 'dark'); // TypeError at runtime on Vercel
}Move any window, localStorage, or document calls into Client Components.
Real-world usage
- Vercel Dashboard: Server Components fetch deployment data from the DB; toggle switches and dropdowns are Client Components
- Shadcn/UI: interactive components (buttons, dialogs, dropdowns) carry
"use client", placed inside Server pages - NextAuth apps: session checks run in Server Components via
auth(); the user avatar dropdown is a Client Component - Payload CMS: admin data tables are server-rendered; the Lexical rich text editor is a Client Component
- Next.js 14+ apps with Server Actions: mutations go through
'use server'functions; everything else stays server-rendered
Follow-up questions
Q: What happens if you import a Server Component inside a Client Component?
A: Next.js throws an error. Client Components cannot import Server Components directly. Use the children prop pattern - pass the Server Component from a Server parent so it is already rendered before the Client Component runs.
Q: Can a Client Component fetch data?
A: Yes, with useEffect plus fetch, SWR, or TanStack Query. Avoid it for initial page data though - you get a second network round trip and a flash of empty content that a Server Component fetch prevents entirely.
Q: What is the RSC payload and how does it differ from HTML?
A: The server sends a binary RSC stream alongside the initial HTML. It contains component props and references so the browser's React reconciler can hydrate Client subtrees without re-fetching data. The client merges both streams silently.
Q: How do Server Actions differ from API routes?
A: Server Actions are functions marked 'use server' that run on the server when triggered by a form or client event. They work without JavaScript enabled (progressive enhancement). API routes are separate HTTP endpoints that always require an explicit fetch call.
Q: (Senior) Why can Server Components be passed as children to Client Components but not imported inside them?
A: When passed as children, Server Components are already rendered to the RSC payload by the server before the Client Component executes. The Client Component receives an opaque reference, not the component function, so no boundary is violated. A direct import would pull the Server Component into the client bundle, which React forbids.
Examples
Dashboard with server data and a client download action
// app/dashboard/page.tsx - Server Component
// Direct DB access - no API key exposure, no extra fetch wrapper needed
export default async function Dashboard() {
const metrics = await db.query('SELECT * FROM metrics ORDER BY date DESC LIMIT 30');
return (
<main>
<h1>Dashboard</h1>
<StatsGrid metrics={metrics} /> {/* Server: plain HTML table */}
<ExportButton data={metrics} /> {/* Client: uses Browser File API */}
</main>
);
}
// components/ExportButton.tsx - Client Component
'use client';
export function ExportButton({ data }: { data: Metric[] }) {
const handleExport = () => {
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob); // Browser-only API
const link = document.createElement('a');
link.href = url;
link.download = 'metrics.json';
link.click();
};
return <button onClick={handleExport}>Export JSON</button>;
}
// Server streams the full dashboard as HTML. Browser hydrates only the export button.Blog post with a client comment section
This follows the pattern from the Next.js docs. Server streams article content; comments are a client island.
// app/blog/[slug]/page.tsx - Server Component
import { getPost } from '@/lib/posts';
import { CommentSection } from './CommentSection';
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug); // DB call on server, secrets never exposed
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<CommentSection postId={post.id} />
</article>
);
}
// app/blog/[slug]/CommentSection.tsx - Client Component
'use client';
import { useState } from 'react';
export function CommentSection({ postId }: { postId: string }) {
const [comments, setComments] = useState<{ id: string; text: string }[]>([]);
const [input, setInput] = useState('');
const handleAdd = () => {
if (!input.trim()) return;
setComments(prev => [...prev, { id: Date.now().toString(), text: input }]);
setInput('');
};
return (
<section>
<h2>Comments</h2>
<input value={input} onChange={e => setInput(e.target.value)} placeholder="Add a comment" />
<button onClick={handleAdd}>Post</button>
<ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>
</section>
);
}
// Article HTML is server-rendered for SEO. Comment section hydrates independently.Server Action for a delete mutation
Functions cannot be passed as props from Server to Client. Server Actions solve this without API routes.
// app/posts/actions.ts - Server Action
'use server';
import { db } from '@/lib/db';
export async function deletePost(formData: FormData) {
const id = formData.get('id') as string;
await db.posts.delete({ where: { id } });
}
// app/posts/PostList.tsx - Client Component
'use client';
import { deletePost } from './actions';
export function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
<form action={deletePost}>
<input type="hidden" name="id" value={post.id} />
<button type="submit">Delete</button>
</form>
</li>
))}
</ul>
);
}
// deletePost serializes data via FormData and runs server-side.
// Works without JavaScript enabled - progressive enhancement.Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.