- Learn
- Stack Essentials
- Next.js
- Server vs Client Components
Intermediate18 min1 prerequisite
Understand the critical distinction between Server and Client Components in Next.js and when to use each.
Server vs Client Components
The Server Component and Client Component distinction is the most important concept to understand when working with AI-generated Next.js code.
The Fundamental Difference
Server Components (Default)
Run only on the server:
Terminal
// app/products/page.tsx
// Server Component by default - no directive needed
export default async function ProductsPage() {
// This code runs on the server
const products = await db.product.findMany()
// Data is already loaded when HTML is sent to browser
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
Characteristics:
- Run at build time or request time
- Can access databases directly
- Can use server-only code
- No JavaScript sent to browser
- Cannot use hooks (useState, useEffect)
- Cannot handle events (onClick, onChange)
Client Components
Run in the browser:
Terminal
// components/Counter.tsx
'use client' // This directive marks it as a Client Component
import { useState } from 'react'
export default function Counter() {
// This code runs in the browser
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
Characteristics:
- JavaScript sent to browser
- Can use React hooks
- Can handle user events
- Cannot be async
- No direct database access
When to Use Each
Use Server Components For:
Terminal
// Fetching data
async function ProductPage({ params }) {
const product = await db.product.findUnique({
where: { id: params.id }
})
return <ProductDetails product={product} />
}
// Accessing backend resources
async function Dashboard() {
const analytics = await getAnalytics()
const reports = await generateReports()
return <DashboardView data={{ analytics, reports }} />
}
// Rendering static content
function TermsOfService() {
return (
<article>
<h1>Terms of Service</h1>
<p>Last updated: ...</p>
{/* Long content */}
</article>
)
}
Use Client Components For:
Terminal
'use client'
// Interactivity
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)}>
{liked ? 'β€οΈ' : 'π€'}
</button>
)
}
// Browser APIs
function LocationTracker() {
const [location, setLocation] = useState(null)
useEffect(() => {
navigator.geolocation.getCurrentPosition(pos => {
setLocation(pos.coords)
})
}, [])
return <div>Lat: {location?.latitude}</div>
}
// Real-time updates
function LiveChat() {
const [messages, setMessages] = useState([])
useEffect(() => {
const ws = new WebSocket('ws://...')
ws.onmessage = e => setMessages(prev => [...prev, e.data])
return () => ws.close()
}, [])
return <MessageList messages={messages} />
}
Decision Flowchart
Terminal
Do you need...
useState, useEffect, hooks?
βββ Yes β Client Component ('use client')
βββ No β
onClick, onChange, events?
βββ Yes β Client Component ('use client')
βββ No β
Browser APIs (window, localStorage, etc)?
βββ Yes β Client Component ('use client')
βββ No β
Direct database access?
βββ Yes β Server Component (default)
βββ No β
Use Server Component (default)
Composing Server and Client Components
Pattern 1: Server Parent, Client Child
The most common pattern:
Terminal
// app/products/[id]/page.tsx (Server)
import { AddToCartButton } from './AddToCartButton'
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Client Component receives server-fetched data */}
<AddToCartButton productId={product.id} />
</div>
)
}
Terminal
// app/products/[id]/AddToCartButton.tsx
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [adding, setAdding] = useState(false)
async function handleAdd() {
setAdding(true)
await addToCart(productId)
setAdding(false)
}
return (
<button onClick={handleAdd} disabled={adding}>
{adding ? 'Adding...' : 'Add to Cart'}
</button>
)
}
Pattern 2: Pass Server Components as Children
Terminal
// components/ClientWrapper.tsx
'use client'
import { useState } from 'react'
export function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && children}
</div>
)
}
Terminal
// app/faq/page.tsx (Server)
import { Accordion } from '@/components/ClientWrapper'
export default async function FAQPage() {
const faqs = await getFAQs()
return (
<div>
{faqs.map(faq => (
<Accordion key={faq.id} title={faq.question}>
{/* This content is Server-rendered, passed as children */}
<div dangerouslySetInnerHTML={{ __html: faq.answer }} />
</Accordion>
))}
</div>
)
}
Pattern 3: Lifting Data Fetching
Move data fetching up:
Terminal
// β Bad: Fetching in Client Component
'use client'
function ProductList() {
const [products, setProducts] = useState([])
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(setProducts)
}, [])
return products.map(p => <Product key={p.id} {...p} />)
}
// β
Good: Fetch in Server, pass to Client
// app/products/page.tsx (Server)
async function ProductsPage() {
const products = await getProducts()
return <ProductList products={products} />
}
// components/ProductList.tsx (Client)
'use client'
function ProductList({ products }) {
const [filter, setFilter] = useState('')
const filtered = products.filter(p =>
p.name.includes(filter)
)
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <Product key={p.id} {...p} />)}
</>
)
}
Props and Boundaries
Serializable Props Only
Props passed from Server to Client must be serializable:
Terminal
// β
Works - primitive values and plain objects
<ClientComponent
name="John"
count={42}
user={{ id: 1, name: 'John' }}
items={['a', 'b', 'c']}
/>
// β Doesn't work - functions, classes, symbols
<ClientComponent
onClick={() => {}} // Function
date={new Date()} // Date object
user={userInstance} // Class instance
/>
The Boundary
Terminal
// Client boundary is established by 'use client'
'use client'
// Everything below is client-side
import { useState } from 'react'
import { HelperComponent } from './Helper' // Also becomes client
export function ClientComponent() {
// ...
}
Key insight: When you import a component into a Client Component, it also becomes a Client Component (unless passed as children).
Common Mistakes
Mistake 1: Using Hooks in Server Components
Terminal
// β Error: useState not allowed in Server Components
export default function ProductPage() {
const [loading, setLoading] = useState(false)
// ...
}
// β
Move state to a Client Component
export default async function ProductPage() {
const product = await getProduct()
return <ProductView product={product} />
}
// components/ProductView.tsx
'use client'
export function ProductView({ product }) {
const [quantity, setQuantity] = useState(1)
// ...
}
Mistake 2: Async Client Components
Terminal
// β Client Components cannot be async
'use client'
export default async function Profile() {
const user = await getUser()
// ...
}
// β
Fetch in Server Component, pass as props
// Server Component
export default async function ProfilePage() {
const user = await getUser()
return <Profile user={user} />
}
// Client Component
'use client'
export function Profile({ user }) {
// Has data from server
}
Mistake 3: Browser APIs in Server Components
Terminal
// β window doesn't exist on server
export default function Nav() {
const path = window.location.pathname
// ...
}
// β
Use as Client Component
'use client'
import { usePathname } from 'next/navigation'
export function Nav() {
const path = usePathname()
// ...
}
Mistake 4: Database in Client Components
Terminal
// β Can't access DB from client
'use client'
export function Dashboard() {
const data = await prisma.user.findMany() // Won't work
// ...
}
// β
Use Server Actions or API routes
'use client'
import { getUsers } from './actions'
export function Dashboard() {
const [users, setUsers] = useState([])
useEffect(() => {
getUsers().then(setUsers)
}, [])
}
AI-Generated Code Patterns
Recognizing Server Components
Terminal
// Server Component indicators:
// - No 'use client' at top
// - async function
// - Direct data fetching
// - No hooks or event handlers
export default async function Page() {
const data = await fetchData()
return <View data={data} />
}
Recognizing Client Components
Terminal
// Client Component indicators:
// - 'use client' at top
// - useState, useEffect, etc.
// - onClick, onChange, etc.
// - Browser API usage
'use client'
import { useState } from 'react'
export default function Form() {
const [value, setValue] = useState('')
return <input onChange={e => setValue(e.target.value)} />
}
When AI Gets It Wrong
Sometimes AI generates code that needs adjustment:
Terminal
// AI might generate this (wrong):
'use client'
export default async function ProductsPage() {
const products = await db.product.findMany()
return <ProductList products={products} />
}
// You should fix to (correct):
// Split into Server and Client
export default async function ProductsPage() {
const products = await db.product.findMany()
return <ProductListClient products={products} />
}
// Separate file with 'use client'
Summary
| Aspect | Server Component | Client Component |
|---|---|---|
| Directive | None (default) | 'use client' |
| Runs where | Server | Browser |
| Data fetching | Direct (async) | Via hooks/actions |
| Hooks | β Not allowed | β Allowed |
| Events | β Not allowed | β Allowed |
| Bundle size | No JS sent | JS sent |
Next Steps
Learn about data fetching patterns and Server Actions in the next lesson.
Mark this lesson as complete to track your progress