When Next.js Breaks
Every classic Next.js error is the server/client split from phase 3 poking through the floor. That's genuinely good news: one mental model explains all five of the messages below, so this phase is shorter than it looks - it's the same lesson wearing five costumes.
The cheat-card
| Symptom / message | Almost always means | Fix |
|---|---|---|
| "Hydration failed" / "text content does not match" | Server HTML ≠ first client render | Find the nondeterminism: dates, random, locale, browser-only branches; render them after mount |
| "useState/useEffect only works in a Client Component" | Hook in a server component | 'use client' on the smallest interactive subtree - not the page |
| "window is not defined" | Browser API running during server render | Move into useEffect; or next/dynamic with ssr: false |
| "Functions cannot be passed directly to Client Components" | Non-serializable prop crossing the boundary | Pass data, not functions - or make it a server action (phase 5) |
| Page shows old data after a write | Missing revalidation | revalidatePath/revalidateTag in the action (phase 5/6) |
| Stale in prod, fine in dev | Static route without a freshness story | revalidate, or revalidate-on-write (phase 6) |
useRouter throws immediately |
Imported from next/router in an app/ project |
Import from next/navigation (phase 2) |
Three of these deserve the full walk-through.
Hydration mismatch: the flagship error
The message is alarming and the cause is almost always mundane:
Error: Text content does not match server-rendered HTML.
Server: "Order placed 11/17/2026, 10:03 PM"
Client: "Order placed 17/11/2026, 22:03"
Remember hydration (phase 1): the browser re-renders your components and attaches to the server's HTML, trusting they'll produce identical output. Anything that renders differently in the two environments breaks that trust. The usual suspects:
- Locale-formatted dates and numbers -
toLocaleString()used the server's locale, then the user's. (The transcript above: US server, European visitor.) Date.now()/Math.random()in render - by definition different on every run.- Browser-only branching -
typeof window !== 'undefined' ? <A/> : <B/>renders<B/>on the server and<A/>on the client. The check prevents the crash and causes the mismatch. - Invalid HTML nesting -
<p>inside<p>,<div>inside<p>: the browser's parser silently rearranges the server HTML, and then React's render doesn't match the rearranged DOM.
The standard fix pattern for values only the client can know correctly:
'use client';
What just happened: both environments render the placeholder identically, hydration succeeds,
then the effect fills in the locale-correct value. You'll see this pattern (or the
suppressHydrationWarning attribute for single unavoidable nodes, like a timestamp in a <time>
tag) throughout production Next codebases.
"window is not defined"
A library (a chart, a map, an editor) reaches for window the moment it's imported - and phase 3
told you client components also render once on the server. Node has no window; the render
crashes before the browser ever gets a chance.
Two fixes, by scope:
// Code you control: touch the browser only after mount
,;
// A whole component you don't control: skip its server render entirely
;
;
What just happened: next/dynamic with ssr: false renders nothing for that component on the
server and mounts it client-side only - the escape hatch for browser-bound libraries. Use it
per-component, not per-page; everything around the chart still gets server-rendered HTML.
The serialization wall
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".
This one's error text is a complete diagnosis: a server component passed onDelete={someFunction}
to a client island. Props cross the boundary as data over the wire (phase 3), and a closure over
server scope can't be serialized into a browser. The error even names your two options:
- The child needs to trigger server work → make the function a server action (
'use server') and pass that - actions are the one function-shaped thing designed to cross (phase 5). - The child needs client behavior → define the handler inside the client component and pass
plain data (
productId, notdeleteProduct) down to it.
Debugging Next: two habits
- Ask "where did this code run?" first. Server terminal log vs browser console log - a
console.login a server component appears in the terminal wherenpm run devruns, not in DevTools. Knowing which log a line lands in is knowing which side of the split it's on, which is half of every diagnosis in this phase. - Reproduce cache issues in a production build. Phase 6's rule bears repeating as a debugging
habit:
npm run build && npm start. The dev server's always-fresh rendering cannot reproduce staleness bugs, and DevTools' "Disable cache" checkbox doesn't touch Next's server-side caches.
Recap
- Hydration mismatch = server HTML and first client render disagree - hunt locale, time, randomness, browser-branches, and invalid nesting; render client-only truths after mount.
window is not defined= browser API at server-render time - effects for your code,dynamic(..., { ssr: false })for libraries.- Functions don't cross the boundary - server actions or client-local handlers do.
- Old data after writes = missing revalidation, not a mysterious cache bug.
- First question, always: which side of the split did this code run on?
[
{
"q": "A page crashes with a hydration mismatch only for European users. The component renders new Date(order.date).toLocaleString(). What's happening?",
"choices": [
"European browsers parse dates differently and throw",
"The server rendered the date in its own locale; the user's browser re-rendered it in theirs, and the outputs differ",
"The date arrives as undefined for non-US timezones",
"toLocaleString is not supported during server rendering"
],
"answer": 1,
"why": [
"No browser throws on toLocaleString - the crash is React refusing mismatched HTML, not a parse error.",
null,
"The value arrives fine everywhere; it's the formatting of the same value that differs.",
"It runs fine on the server - using the server's locale, which is precisely the problem."
],
"explain": "Hydration requires identical output in both environments. Locale formatting is environment-dependent, so format after mount (or suppress the warning on that one node)."
},
{
"q": "A server component wants a client-side DeleteButton to remove an item. Passing onDelete={() => db.items.delete(id)} throws a serialization error. The right fix?",
"choices": [
"Mark the DeleteButton file 'use server' so the function is allowed",
"Make the delete a server action and pass that to the button",
"Stringify the function and eval it client-side",
"Move the database call into the DeleteButton's onClick"
],
"answer": 1,
"why": [
"'use server' marks functions as remotely callable, not components - and the button is client by necessity (it has onClick).",
null,
"Eval'ing serialized closures is a security hole and the closure's server scope still wouldn't exist in the browser.",
"That imports database code into the client bundle - the build fails, and it would expose credentials if it didn't."
],
"explain": "Server actions are the one function-shaped thing designed to cross the boundary: the client gets a reference, invocation runs on the server."
}
]
← Phase 6: Static, Dynamic, and the Cache · Guide overview · Phase 8: Where to Go Next →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 2 questions
1. A page crashes with a hydration mismatch only for European users. The component renders new Date(order.date).toLocaleString(). What's happening?
2. A server component wants a client-side DeleteButton to remove an item. Passing onDelete={() => db.items.delete(id)} throws a serialization error. The right fix?