When you use next-themes and read useTheme() directly in your render, React throws a hydration error. Here's exactly why — and the one-line fix.
What hydration is
Next.js renders your components twice:
- On the server — to generate the initial HTML sent to the browser
- On the client — to attach event listeners to that HTML
The second pass is called hydration. React walks the component tree, compares its output to the HTML already on the page, and wires up interactivity. If the output doesn't match, React throws a hydration error and re-renders the entire tree from scratch.
How next-themes persists the choice
When you call setTheme, next-themes does two things:
- Sets the theme attribute on
<html>so CSS variables update immediately - Writes the chosen value to
localStorageso the choice survives a page refresh
On the next visit, next-themes injects a blocking script into <head> — before any React code runs — that reads localStorage and sets the attribute on <html> immediately. This prevents a flash of the default theme on load.
That script is client-only. The server never ran it, never read localStorage, and has no idea what the user previously chose. It renders with whatever defaultTheme you configured.
In my project
next-themes is configured with defaultTheme="summer" and attribute="data-season". So every reload after picking a non-default season gives: server renders summer → client hydrates with autumn → mismatch.
Why next-themes causes this
next-themes stores the user's chosen theme in localStorage. The server has no access to localStorage — it runs in Node.js, not in the browser. So on the server, useTheme() returns undefined for theme.
The typical fallback:
const { theme } = useTheme()
const current = theme ?? 'default'On the server, theme is undefined, so current becomes 'default'. The server writes HTML with default styles. Then the browser loads, React hydrates — and useTheme() can read localStorage. If the user picked something different, theme resolves to that value. Server and client disagree. Mismatch. Error.
In my code
The fallback is const season = (theme as Season) ?? 'summer'. On the server, theme is undefined → season = 'summer'. On the client, localStorage has the user's last pick — 'autumn', 'winter', etc. Two different values from the same render.
The wrong approach
No guard. On the server, useTheme() returns undefined for theme. On the client, it reads localStorage and returns the stored value. The two renders produce different output:
// server: theme = undefined → current = 'default'
// client: theme = 'stored' → current = 'stored'
const current = theme ?? 'default' // ❌ server and client disagreeThe server and client run in fundamentally different environments. The server never has access to user-specific state like localStorage, cookies, or the system clock in the user's timezone.
The fix: a mounted guard
const { theme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const current = mounted ? (theme ?? 'default') : 'default'useState(false) initialises the same way on server and client. useEffect only runs in the browser — never on the server. So:
- Server:
mountedisfalse→currentis'default' - Client first render:
mountedis stillfalse→currentis'default'← matches server - After hydration:
useEffectfires →mountedbecomestrue→currentupdates to the real value fromlocalStorage
In my code
const season: Season = mounted ? ((theme as Season) ?? 'summer') : 'summer''summer' is the defaultTheme passed to ThemeProvider. Season is a string union of the four season values.
Before vs after
The rule
Any value that differs between server and client — localStorage, window, navigator, Date.now(), system locale — must not be read during the initial render. Read it in useEffect, after hydration has completed.
next-themes is the most common place this bites you, but the same pattern applies anywhere you use typeof window !== 'undefined' guards in render logic.
Key Points
useTheme()returnstheme: undefinedon the server — always. The server has no browser, nolocalStorage, no user context. This is true even if you setdefaultThemeinThemeProvider— that default controls the CSS attribute fallback; it does not populate the JSthemevalue during SSR.- Reading it directly causes a hydration mismatch. Your fallback works fine in isolation —
undefined ?? 'default'gives the default on the server. But on the client,useTheme()readslocalStorageand returns whatever the user last picked. Server said one thing, client says another — React throws. - Fix: a
mountedguard. Addconst [mounted, setMounted] = useState(false)anduseEffect(() => { setMounted(true) }, []). Usemounted ? theme : 'default'instead oftheme ?? 'default'. Both passes render the default on first pass — hydration succeeds. After hydration,useEffectfires,mountedflips, and React re-renders with the real value. - The same rule applies to any browser-only value.
localStorage,window,navigator,Date.now()in a user timezone,Intllocale formatting — anything that differs between server and client must not be read during the initial render. Defer it touseEffect.
The next post in this series covers the useEffect + setState lint rule this fix triggers, and how useSyncExternalStore replaces it entirely.
Why the useEffect + setState mounted guard triggers a lint rule, and how useSyncExternalStore is the React-official way to handle server/client value differences.
↗ yi.dev / blog