NextJS DOC
Part 4 of 15Next.js Server and Client Components: Complete Architecture Guide
Next.js Server and Client Components: Complete Architecture Guide
Introduction: The Component Model Revolution
React Server Components (RSC) represent the most significant shift in React's architecture since hooks. In Next.js, this model is the foundation of the App Router, enabling a new way of thinking about where code runs and how data flows.
The core insight: not every component needs to ship JavaScript to the browser. Server Components render on the server and send only their output (HTML + RSC Payload) to the client. Client Components are the "traditional" React components—they hydrate and run in the browser.
This guide dissects how the server/client boundary works, composition patterns, and the mental models needed to build efficient Next.js applications.
The Mental Model
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER/CLIENT COMPONENT MODEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SERVER │ │
│ │ │ │
│ │ Server Components │ │
│ │ ───────────────── │ │
│ │ • Run ONLY on server │ │
│ │ • Can be async (direct data fetching) │ │
│ │ • Access to server resources (DB, filesystem, secrets) │ │
│ │ • Zero JavaScript sent to client │ │
│ │ • Cannot use state, effects, or browser APIs │ │
│ │ │ │
│ │ Output: RSC Payload (serialized component tree) │ │
│ │ │ │
│ └──────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ │ Props (must be serializable) │
│ │ RSC Payload │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CLIENT │ │
│ │ │ │
│ │ Client Components ('use client') │ │
│ │ ───────────────────────────────── │ │
│ │ • Prerendered on server (HTML) │ │
│ │ • Hydrated on client (JavaScript) │ │
│ │ • Can use state, effects, event handlers │ │
│ │ • Access to browser APIs │ │
│ │ • JavaScript shipped to client bundle │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
When to Use Each Component Type
Decision Framework
┌─────────────────────────────────────────────────────────────────────┐
│ COMPONENT TYPE DECISION TREE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Does the component need... │
│ │
│ ├── State (useState, useReducer)? │
│ │ └── YES → Client Component │
│ │ │
│ ├── Effects (useEffect, useLayoutEffect)? │
│ │ └── YES → Client Component │
│ │ │
│ ├── Event handlers (onClick, onChange, onSubmit)? │
│ │ └── YES → Client Component │
│ │ │
│ ├── Browser APIs (localStorage, window, navigator)? │
│ │ └── YES → Client Component │
│ │ │
│ ├── Custom hooks that use any of the above? │
│ │ └── YES → Client Component │
│ │ │
│ ├── Data fetching from database/API? │
│ │ └── Server Component (preferred) │
│ │ │
│ ├── Access to secrets (API keys, tokens)? │
│ │ └── Server Component (required) │
│ │ │
│ ├── Heavy dependencies (markdown parser, syntax highlighter)? │
│ │ └── Server Component (avoid shipping JS) │
│ │ │
│ └── None of the above (purely presentational)? │
│ └── Server Component (default) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Comparison Table
| Capability | Server Component | Client Component |
|---|---|---|
| Fetch data | ✅ Direct, async | ⚠️ useEffect/React Query |
| Access backend resources | ✅ DB, filesystem | ❌ No |
| Keep secrets secure | ✅ Never exposed | ❌ Exposed in bundle |
| Use state/effects | ❌ No | ✅ Yes |
| Use event handlers | ❌ No | ✅ Yes |
| Use browser APIs | ❌ No | ✅ Yes |
| Use custom hooks | ⚠️ Server-only hooks | ✅ Yes |
| Reduce JS bundle | ✅ Zero JS shipped | ❌ Adds to bundle |
| Stream rendering | ✅ Yes | ⚠️ After hydration |
How It Works Under the Hood
Server-Side Rendering Flow
┌─────────────────────────────────────────────────────────────────────┐
│ RSC RENDERING PIPELINE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. REQUEST RECEIVED │
│ └── Next.js identifies route segments │
│ │
│ 2. SERVER COMPONENT RENDERING │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ <Page> (Server Component) │ │
│ │ <Header /> (Server Component) │ │
│ │ <Sidebar> (Server Component) │ │
│ │ <NavLinks /> (Server Component) │ │
│ │ </Sidebar> │ │
│ │ <Main> (Server Component) │ │
│ │ <Content /> (Server Component) │ │
│ │ <LikeButton> (Client Component) ← PLACEHOLDER │ │
│ │ </Main> │ │
│ │ </Page> │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 3. RSC PAYLOAD GENERATED │
│ { │
│ // Serialized Server Component output │
│ "tree": { "type": "div", "children": [...] }, │
│ // Client Component references │
│ "clientRefs": { "LikeButton": "./like-button.js" }, │
│ // Props for Client Components │
│ "clientProps": { "LikeButton": { "likes": 42 } } │
│ } │
│ │
│ 4. HTML PRERENDERING │
│ └── Client Components also rendered to HTML (SSR) │
│ └── Initial HTML sent to browser immediately │
│ │
│ 5. CLIENT HYDRATION │
│ └── React hydrates Client Component placeholders │
│ └── Event handlers attached │
│ └── App becomes interactive │
│ │
└─────────────────────────────────────────────────────────────────────┘
What is the RSC Payload?
The RSC Payload is a compact binary format containing:
- Rendered Server Component output - The actual HTML/virtual DOM
- Client Component placeholders - References to where Client Components mount
- Serialized props - Data passed from Server to Client Components
- Streaming boundaries - For progressive rendering
// Conceptual representation of RSC Payload
interface RSCPayload {
// Server Component tree (serialized React elements)
tree: SerializedReactNode;
// References to Client Component modules
clientModules: {
[componentId: string]: {
id: string; // Module ID
name: string; // Export name
chunks: string[]; // JS chunk files
};
};
// Props passed to Client Components (must be serializable)
clientProps: {
[componentId: string]: SerializableProps;
};
}
The 'use client' Directive
Understanding the Boundary
'use client' declares a boundary between server and client module graphs. It doesn't just mark one component—it marks an entry point into the client bundle.
// app/ui/counter.tsx
'use client'; // This file and ALL its imports are in client bundle
import { useState } from 'react';
import { formatNumber } from './utils'; // Also in client bundle!
import { Button } from './button'; // Also in client bundle!
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{formatNumber(count)}</p>
<Button onClick={() => setCount(c => c + 1)}>
Increment
</Button>
</div>
);
}
Key insight: Once you cross the 'use client' boundary, everything imported by that file is part of the client bundle, regardless of whether those files have the directive.
Placement Strategy
┌─────────────────────────────────────────────────────────────────────┐
│ 'use client' BOUNDARY PLACEMENT │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ BAD: Too high (entire subtree becomes client) │
│ │
│ 'use client' // In layout.tsx │
│ export function Layout({ children }) { │
│ return <div>{children}</div> // ALL children are client now │
│ } │
│ │
│ ═══════════════════════════════════════════════════════════════ │
│ │
│ ✅ GOOD: As low as possible (minimal client code) │
│ │
│ // layout.tsx (Server Component) │
│ import { InteractiveNav } from './interactive-nav' │
│ │
│ export function Layout({ children }) { │
│ return ( │
│ <div> │
│ <StaticHeader /> {/* Server */} │
│ <InteractiveNav /> {/* Client - only this */} │
│ <main>{children}</main> {/* Can be Server */} │
│ </div> │
│ ) │
│ } │
│ │
│ // interactive-nav.tsx │
│ 'use client' // Boundary only here │
│ export function InteractiveNav() { │
│ const [open, setOpen] = useState(false) │
│ // ... │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────┘
Composition Patterns
Pattern 1: Server Component with Client Islands
The most common pattern—Server Components with interactive "islands":
// app/posts/[id]/page.tsx (Server Component)
import { getPost, getComments } from '@/lib/data';
import { CommentForm } from './comment-form'; // Client
import { ShareButton } from './share-button'; // Client
import { formatDate } from '@/lib/utils';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Direct data fetching - runs on server
const [post, comments] = await Promise.all([
getPost(id),
getComments(id),
]);
return (
<article>
{/* Static content - Server Component */}
<header>
<h1>{post.title}</h1>
<time>{formatDate(post.publishedAt)}</time>
<p>{post.author.name}</p>
</header>
{/* Heavy markdown rendering - stays on server */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: post.htmlContent }}
/>
{/* Interactive islands - Client Components */}
<footer>
<ShareButton url={`/posts/${id}`} title={post.title} />
</footer>
{/* Comments section */}
<section>
<h2>Comments ({comments.length})</h2>
{/* Static comment list - Server */}
<ul>
{comments.map((comment) => (
<li key={comment.id}>
<p>{comment.text}</p>
<small>{comment.author}</small>
</li>
))}
</ul>
{/* Interactive form - Client */}
<CommentForm postId={id} />
</section>
</article>
);
}
// app/posts/[id]/comment-form.tsx
'use client';
import { useState, useTransition } from 'react';
import { addComment } from './actions';
export function CommentForm({ postId }: { postId: string }) {
const [text, setText] = useState('');
const [isPending, startTransition] = useTransition();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await addComment(postId, text);
setText('');
});
}
return (
<form onSubmit={handleSubmit}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Write a comment..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}
Pattern 2: Children Slot (Server in Client)
Pass Server Components as children to Client Components:
// app/ui/modal.tsx (Client Component)
'use client';
import { useState, createContext, useContext } from 'react';
interface ModalContextValue {
isOpen: boolean;
open: () => void;
close: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<ModalContext.Provider
value={{
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
}}
>
{children}
</ModalContext.Provider>
);
}
export function ModalTrigger({ children }: { children: React.ReactNode }) {
const context = useContext(ModalContext);
if (!context) throw new Error('ModalTrigger must be inside Modal');
return (
<button onClick={context.open}>
{children}
</button>
);
}
export function ModalContent({ children }: { children: React.ReactNode }) {
const context = useContext(ModalContext);
if (!context) throw new Error('ModalContent must be inside Modal');
if (!context.isOpen) return null;
return (
<div className="modal-overlay" onClick={context.close}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={context.close}>
×
</button>
{children} {/* Server Components can go here! */}
</div>
</div>
);
}
export function useModal() {
const context = useContext(ModalContext);
if (!context) throw new Error('useModal must be inside Modal');
return context;
}
// app/products/page.tsx (Server Component)
import { Modal, ModalTrigger, ModalContent } from '@/ui/modal';
import { ProductDetails } from './product-details'; // Server Component!
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
{products.map((product) => (
<Modal key={product.id}>
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<ModalTrigger>
<span>View Details</span>
</ModalTrigger>
</div>
<ModalContent>
{/* This is a Server Component inside a Client Component! */}
{/* It was pre-rendered and passed via children */}
<ProductDetails productId={product.id} />
</ModalContent>
</Modal>
))}
</div>
);
}
// app/products/product-details.tsx (Server Component)
// No 'use client' - this is a Server Component
import { getProductDetails } from '@/lib/data';
export async function ProductDetails({ productId }: { productId: string }) {
// This runs on the server!
const details = await getProductDetails(productId);
return (
<div>
<h2>{details.name}</h2>
<p>{details.description}</p>
<ul>
{details.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
{/* Heavy image gallery, reviews, etc. - all server rendered */}
</div>
);
}
Pattern 3: Context Providers
Wrap the entire app with providers while keeping children as Server Components:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
// Create QueryClient inside component to avoid sharing between requests
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx (Server Component)
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
{/* Provider is Client Component, but children can be Server */}
<Providers>
{children}
</Providers>
</body>
</html>
);
}
Important: Place providers as deep as possible. Wrapping only {children} instead of the entire <html> lets Next.js optimize static parts.
Pattern 4: Render Props for Flexibility
// app/ui/data-table.tsx
'use client';
import { useState, useMemo } from 'react';
interface DataTableProps<T> {
data: T[];
columns: Array<{
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}>;
renderRow?: (row: T, index: number) => React.ReactNode;
}
export function DataTable<T extends { id: string }>({
data,
columns,
renderRow,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<keyof T | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [filter, setFilter] = useState('');
const processedData = useMemo(() => {
let result = [...data];
// Filter
if (filter) {
result = result.filter((row) =>
Object.values(row).some((val) =>
String(val).toLowerCase().includes(filter.toLowerCase())
)
);
}
// Sort
if (sortKey) {
result.sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
}
return result;
}, [data, sortKey, sortDir, filter]);
return (
<div>
<input
type="search"
placeholder="Filter..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<table>
<thead>
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
onClick={() => {
if (sortKey === col.key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(col.key);
setSortDir('asc');
}
}}
>
{col.header}
{sortKey === col.key && (sortDir === 'asc' ? ' ↑' : ' ↓')}
</th>
))}
</tr>
</thead>
<tbody>
{processedData.map((row, index) =>
renderRow ? (
renderRow(row, index)
) : (
<tr key={row.id}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
)
)}
</tbody>
</table>
</div>
);
}
// app/users/page.tsx (Server Component)
import { DataTable } from '@/ui/data-table';
import { getUsers } from '@/lib/data';
export default async function UsersPage() {
const users = await getUsers(); // Server-side data fetch
return (
<DataTable
data={users}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'email', header: 'Email' },
{
key: 'role',
header: 'Role',
render: (role) => <span className={`badge-${role}`}>{role}</span>,
},
{ key: 'createdAt', header: 'Joined' },
]}
/>
);
}
Data Serialization: The Boundary Rules
What Can Cross the Boundary
Props passed from Server to Client Components must be serializable:
// ✅ Serializable (can pass to Client Components)
const validProps = {
// Primitives
string: 'hello',
number: 42,
boolean: true,
null: null,
undefined: undefined,
// Plain objects (no methods)
object: { key: 'value' },
// Arrays
array: [1, 2, 3],
// Dates (serialized to ISO string)
date: new Date(),
// React elements (Server Components already rendered)
element: <ServerComponent />,
};
// ❌ NOT Serializable (cannot pass to Client Components)
const invalidProps = {
// Functions
callback: () => console.log('hi'),
eventHandler: (e: Event) => {},
// Classes
instance: new MyClass(),
// Symbols
symbol: Symbol('id'),
// Maps and Sets
map: new Map(),
set: new Set(),
// Circular references
circular: (() => { const o = {}; o.self = o; return o; })(),
};
Server Actions as Event Handlers
Use Server Actions to pass "functions" to Client Components:
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function addToCart(productId: string) {
const cart = await db.cart.add(productId);
revalidatePath('/cart');
return cart;
}
export async function removeFromCart(productId: string) {
await db.cart.remove(productId);
revalidatePath('/cart');
}
// app/products/add-to-cart-button.tsx
'use client';
import { useTransition } from 'react';
import { addToCart } from '@/app/actions';
interface AddToCartButtonProps {
productId: string;
// Server Action can be passed as a prop!
action?: typeof addToCart;
}
export function AddToCartButton({
productId,
action = addToCart,
}: AddToCartButtonProps) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await action(productId);
});
}}
>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Third-Party Component Integration
Problem: Library Without 'use client'
Many npm packages use client-only features but don't have the directive:
// This will ERROR if used in Server Component:
import { Carousel } from 'acme-carousel'; // Uses useState internally
export default function Page() {
return <Carousel />; // ❌ Error: useState is not available
}
Solution: Wrapper Component
// app/ui/carousel.tsx
'use client';
// Re-export with 'use client' boundary
export { Carousel } from 'acme-carousel';
// Or with customization:
import { Carousel as AcmeCarousel } from 'acme-carousel';
export function Carousel(props: CarouselProps) {
return <AcmeCarousel {...props} className="my-custom-class" />;
}
// Now usable in Server Components:
import { Carousel } from '@/ui/carousel';
export default function Page() {
return <Carousel />; // ✅ Works
}
Barrel File Pattern for Third-Party Components
// app/ui/client-components.tsx
'use client';
// All third-party client components in one boundary
export { Carousel } from 'acme-carousel';
export { DatePicker } from 'react-datepicker';
export { Toast, Toaster } from 'sonner';
export { motion, AnimatePresence } from 'framer-motion';
// Usage in Server Component
import { Carousel, DatePicker, motion } from '@/ui/client-components';
Environment Safety
Preventing Server Code in Client
// lib/db.ts
import 'server-only'; // Build error if imported in Client Component
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();
export async function getSecretData() {
// Uses process.env.DATABASE_URL - should never reach client
return db.secret.findMany();
}
// app/ui/dashboard.tsx
'use client';
import { getSecretData } from '@/lib/db'; // ❌ Build Error!
// Error: You're importing a component that needs "server-only"
Preventing Client Code in Server
// lib/analytics.ts
import 'client-only'; // Build error if imported in Server Component
export function trackEvent(event: string, data: object) {
// Uses window - must run in browser
window.gtag('event', event, data);
}
// app/page.tsx (Server Component)
import { trackEvent } from '@/lib/analytics'; // ❌ Build Error!
Environment Variables
// Server Component - has access to all env vars
export default function Page() {
const apiKey = process.env.API_KEY; // ✅ Available
const dbUrl = process.env.DATABASE_URL; // ✅ Available
}
// Client Component - only NEXT_PUBLIC_ vars
'use client';
export function ClientComponent() {
const apiKey = process.env.API_KEY; // ❌ undefined
const publicKey = process.env.NEXT_PUBLIC_API_KEY; // ✅ Available
}
Performance Optimization Strategies
1. Minimize Client Component Size
// ❌ BAD: Large client boundary
'use client';
import { HugeLibrary } from 'huge-library';
import { AnotherBigLib } from 'another-big-lib';
export function InteractiveSection() {
const [state, setState] = useState();
// ... uses HugeLibrary and AnotherBigLib
}
// ✅ GOOD: Separate concerns, smaller boundaries
// interactive-button.tsx
'use client';
export function InteractiveButton() {
const [clicked, setClicked] = useState(false);
return <button onClick={() => setClicked(true)}>Click me</button>;
}
// page.tsx (Server Component)
import { processWithHugeLibrary } from '@/lib/server-utils';
import { InteractiveButton } from './interactive-button';
export default async function Page() {
// Heavy processing on server
const data = await processWithHugeLibrary();
return (
<div>
<HeavyContent data={data} /> {/* Server rendered */}
<InteractiveButton /> {/* Minimal client JS */}
</div>
);
}
2. Lazy Load Heavy Client Components
// app/page.tsx
import dynamic from 'next/dynamic';
// Only load when needed
const HeavyChart = dynamic(() => import('./heavy-chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Skip SSR if not needed
});
const CodeEditor = dynamic(
() => import('@monaco-editor/react').then((mod) => mod.Editor),
{
loading: () => <div>Loading editor...</div>,
ssr: false,
}
);
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
<CodeEditor />
</div>
);
}
3. Streaming Heavy Server Components
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
{/* Fast - renders immediately */}
<DashboardHeader />
{/* Slow data - streams in */}
<Suspense fallback={<ChartSkeleton />}>
<SlowAnalyticsChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<SlowDataTable />
</Suspense>
</div>
);
}
// These are Server Components that fetch slow data
async function SlowAnalyticsChart() {
const data = await fetchAnalytics(); // Takes 2s
return <Chart data={data} />;
}
async function SlowDataTable() {
const data = await fetchTableData(); // Takes 3s
return <Table data={data} />;
}
Common Mistakes and Solutions
Mistake 1: Using Hooks in Server Components
// ❌ WRONG
export default function Page() {
const [count, setCount] = useState(0); // Error!
useEffect(() => {}, []); // Error!
return <div>{count}</div>;
}
// ✅ CORRECT: Extract to Client Component
import { Counter } from './counter';
export default function Page() {
return <Counter />;
}
// counter.tsx
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
Mistake 2: Importing Server Component into Client
// ❌ WRONG: Can't import Server Component into Client
'use client';
import { ServerComponent } from './server-component';
export function ClientWrapper() {
return <ServerComponent />; // This won't work as expected
}
// ✅ CORRECT: Use children pattern
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div className="wrapper">{children}</div>;
}
// In Server Component:
import { ClientWrapper } from './client-wrapper';
import { ServerComponent } from './server-component';
export default function Page() {
return (
<ClientWrapper>
<ServerComponent /> {/* Passed as children */}
</ClientWrapper>
);
}
Mistake 3: Passing Non-Serializable Props
// ❌ WRONG: Functions can't be serialized
export default function Page() {
const handleClick = () => console.log('clicked');
return <ClientButton onClick={handleClick} />; // Error!
}
// ✅ CORRECT: Use Server Actions
import { handleClick } from './actions';
export default function Page() {
return <ClientButton action={handleClick} />;
}
// actions.ts
'use server';
export async function handleClick() {
console.log('clicked on server');
}
Key Takeaways
-
Server Components are the default: In App Router, components without
'use client'are Server Components. Start here, add'use client'only when needed. -
'use client'marks a boundary: Everything imported by a client file becomes part of the client bundle. Place boundaries as low as possible. -
Children pattern enables composition: Pass Server Components as
childrento Client Components to maintain server rendering benefits. -
Props must be serializable: Only JSON-serializable data can cross the server/client boundary. Use Server Actions for functions.
-
Use
server-onlyandclient-only: Prevent accidental imports across environments with explicit guards. -
Context requires Client Components: Providers must be Client Components, but their children can still be Server Components.
-
Third-party components need wrappers: Create thin
'use client'wrappers for npm packages that lack the directive. -
Suspense enables streaming: Wrap slow Server Components in Suspense to stream content progressively.
-
Heavy processing belongs on server: Markdown parsing, syntax highlighting, data transformations—keep them in Server Components.
-
Minimize client boundaries: Every
'use client'file adds to the JavaScript bundle. Keep interactive parts small and focused.
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min