How client-side rendering (CSR) works in Next.js
Client-side rendering (CSR) in Next.js means that a Client Component, marked with 'use client', runs and re-renders in the browser after the server delivers an initial HTML shell from a Server Component.
Theory
TL;DR
- Next.js never does pure CSR like Create React App. Every page starts as a Server Component that sends real HTML, not an empty
<div>. - Client Components (
'use client') hydrate in the browser and handle state, events, and browser APIs. - React 18's
hydrateRootattaches to existing DOM nodes without re-rendering the tree from scratch. - Use CSR for user-triggered fetches, browser APIs, and real-time UI. Default to Server Components for data loading.
- Each
'use client'module adds to the client JS bundle. Keep the boundary as deep in the tree as possible.
Quick example
// app/dashboard/page.tsx - Server Component
import ClientDashboard from './ClientDashboard'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ClientDashboard /> {/* hydrates in browser */}
</div>
)
}
// app/dashboard/ClientDashboard.tsx
'use client'
import { useState, useEffect } from 'react'
export default function ClientDashboard() {
const [data, setData] = useState<{ name: string } | null>(null)
useEffect(() => {
fetch('/api/user-data')
.then(res => res.json())
.then(setData)
}, [])
return <div>{data ? `User: ${data.name}` : 'Loading...'}</div>
}Server sends <h1>Dashboard</h1><div>Loading...</div>. Browser receives it, hydrateRoot attaches React to the existing DOM, the useEffect runs, data arrives, and the component shows "User: John". Two phases, one page.
How CSR in Next.js differs from a pure SPA
Create React App sends an empty <div id="root"></div> plus the full JS bundle. The browser has to download, parse, and execute everything before showing a single character. SEO crawlers see nothing. TTFB is fast but First Contentful Paint is slow.
Next.js never does this. Even when you mark a component 'use client', the page still begins as a Server Component that generates actual HTML. The Client Component also renders on the server for its initial snapshot. The browser receives that snapshot as HTML, hydrateRoot attaches to the existing DOM nodes, and the component becomes interactive. No full re-render. No blank screen while JS loads.
That is the core idea: in Next.js, CSR always layers on top of a server-rendered shell.
When to use
- User-triggered data loads (search, filter, pagination): CSR with
useEffectandfetch, or TanStack Query for caching and revalidation. - Browser APIs (
localStorage,navigator.geolocation,WebSocket, Canvas): these don't exist in Node.js, so the component must be a Client Component. - Real-time widgets (live charts, chat, notifications): Client Components with WebSocket or polling.
- SEO-critical or static content: stay in Server Components. No JS is sent to the browser for these.
- Performance on mobile matters: minimize
'use client'boundaries. Each one grows the client bundle.
How hydration works internally
The browser receives the HTML string and a JS bundle from the Next.js server (Node.js or Edge Runtime). React calls hydrateRoot(container, element), walks the existing DOM, and attaches its internal fiber tree to the nodes without touching them. Then all useEffect calls run. V8 executes only the modules reachable from 'use client' boundaries. Server Component code never reaches the browser.
With React Server Components and <Suspense>, the browser can start hydrating pieces of the page as they stream in, without waiting for the full response. A Client Component inside a Suspense boundary can become interactive while the server is still sending the rest of the page.
If the server-rendered HTML doesn't match what React expects to see in the browser, you get a hydration mismatch. React re-renders that subtree from scratch, which cancels out the SSR benefit for that component.
Common mistakes
Marking a full page as a Client Component for no reason.
// Wrong: entire page becomes CSR, no SSR benefit
'use client'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData) }, [])
return <div>{data?.name}</div>
}
// Right: Server Component fetches data, no client JS needed
export default async function Page() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.name}</div>
}The wrong version sends an empty shell on first load, hurts SEO, and ships unnecessary JS to the browser.
Using browser globals outside of useEffect.
// Wrong: crashes during server render
const [width, setWidth] = useState(window.innerWidth) // window is undefined in Node.js
// Right: safe initial value, update after mount
const [width, setWidth] = useState(0)
useEffect(() => setWidth(window.innerWidth), [])Hydration mismatch from non-deterministic values.
If the initial render depends on Date.now(), Math.random(), or any browser state, the server and browser produce different HTML. React warns and re-renders. Always give browser-dependent state a safe server-compatible default and set the real value in useEffect.
No error handling in CSR fetches.
A failed fetch in useEffect with no try/catch silently breaks the component. The user sees a blank section. Add a try/catch and render an error state, or use SWR which handles this by default.
'use client' too high in the tree.
I've seen teams add 'use client' at the layout level by default. That sends the entire subtree as client JS. Moving the boundary down to the actual interactive leaf component cut bundle size in half on one project I worked on.
Real-world usage
- Vercel Dashboard: CSR for live metric charts and filter controls via
useEffectfetches. - Linear: Client Components with WebSockets for real-time collaborative editing.
- Clerk:
'use client'for session modals andlocalStoragetoken handling. - Recharts inside Next.js apps: always CSR, because the charting library needs
windowand canvas. - Any search-as-you-type input:
useEffect+AbortControllerto cancel stale requests.
Follow-up questions
Q: How does Next.js hydration differ from Create React App?
A: CRA sends an empty div and the browser renders everything from JS. Next.js sends pre-rendered HTML from Server Components, so content is visible immediately. hydrateRoot then attaches React to the existing DOM without a full re-render.
Q: What triggers a hydration mismatch?
A: The server and browser rendering different HTML for the same component. Common causes: Date.now(), Math.random(), reading window or localStorage in the initial render. Fix: move browser-only logic into useEffect.
Q: useEffect + fetch or SWR for CSR data fetching?
A: useEffect for one-off fetches where you don't need caching. SWR or TanStack Query for stale-while-revalidate, deduplication, and refetch on focus. Most production apps end up with SWR.
Q: Does 'use client' propagate to all imports inside the file?
A: Yes. Every module imported by a Client Component also runs in the browser. That is why you push the boundary as deep as possible: only the components that actually need state or events get the 'use client' label.
Q: In App Router v14+, how does partial prerendering interact with CSR?
A: The static shell prebuilds on the server at deploy time. Client Components inside Suspense boundaries hole-punch into that shell and hydrate independently. Dynamic slots don't block the static content, so you get instant static HTML plus isolated CSR hydration without full revalidation.
Examples
Basic: search input with client fetch
// app/search/SearchBox.tsx
'use client'
import { useState } from 'react'
interface Result { id: string; title: string }
export default function SearchBox() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Result[]>([])
const search = async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
setResults(await res.json())
}
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<button onClick={search}>Search</button>
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</div>
)
}The server sends the static page. The search box hydrates in the browser and fetches results on demand. No page reload on every search.
Intermediate: server data passed to a client filter
// app/problems/page.tsx - Server Component
import { db } from '@/lib/db'
import { ProblemFilter } from './ProblemFilter'
export default async function ProblemsPage() {
const problems = await db.problem.findMany({
select: { id: true, name: true, difficulty: true }
})
return (
<div>
<h1>Problems</h1>
<ProblemFilter initialProblems={problems} />
</div>
)
}
// app/problems/ProblemFilter.tsx - Client Component
'use client'
import { useState } from 'react'
interface Problem { id: string; name: string; difficulty: number }
export function ProblemFilter({ initialProblems }: { initialProblems: Problem[] }) {
const [difficulty, setDifficulty] = useState<number | null>(null)
const filtered = difficulty
? initialProblems.filter(p => p.difficulty === difficulty)
: initialProblems
return (
<div>
<button onClick={() => setDifficulty(null)}>All</button>
<button onClick={() => setDifficulty(1)}>Easy</button>
<button onClick={() => setDifficulty(2)}>Medium</button>
<button onClick={() => setDifficulty(3)}>Hard</button>
<ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
</div>
)
}The server fetches everything once and passes it as props. Filtering runs in memory on the client. No API call on every button click, no round-trip to the server for each filter change.
Advanced: dynamic import to disable SSR for a browser-only component
Some components use APIs that only exist in the browser. A code editor built on Monaco, a canvas renderer, or a WebSocket client will crash the server render if imported normally.
// app/problem/[id]/page.tsx - Server Component
import dynamic from 'next/dynamic'
// ssr: false means this never runs in Node.js
const CodeEditor = dynamic(
() => import('@/components/CodeEditor'),
{ ssr: false, loading: () => <p>Loading editor...</p> }
)
export default function ProblemPage({ params }: { params: { id: string } }) {
return (
<div>
<h1>Problem {params.id}</h1>
<CodeEditor /> {/* browser only */}
</div>
)
}
// components/CodeEditor.tsx
'use client'
import { useEffect, useRef } from 'react'
export default function CodeEditor() {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// Monaco uses window and canvas - safe here, we are in the browser
if (ref.current) {
// initialize editor
}
}, [])
return <div ref={ref} style={{ height: 400 }} />
}ssr: false guarantees the component never runs in Node.js. The page still ships server-rendered HTML with the loading placeholder visible immediately. The editor appears after hydration.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.