Fixing the next-themes hydration mismatch

Why reading useTheme() directly causes a React hydration error, and how a mounted guard fixes it.

nextjsreactnext-themeshydrationssr

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:

  1. On the server — to generate the initial HTML sent to the browser
  2. 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:

  1. Sets the theme attribute on <html> so CSS variables update immediately
  2. Writes the chosen value to localStorage so 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 undefinedseason = '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 disagree

The 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: mounted is falsecurrent is 'default'
  • Client first render: mounted is still falsecurrent is 'default' ← matches server
  • After hydration: useEffect fires → mounted becomes truecurrent updates to the real value from localStorage

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

Without guard (wrong)
Server renders
useTheme() → undefined, current = 'default'
Browser receives HTML
shows default
React hydrates
localStorage → 'stored', current = 'stored'
❌ Mismatch
server said default, client says stored — React throws
With mounted guard (right)
Server renders
mounted = false, current = 'default'
Browser receives HTML
shows default
React hydrates
mounted = false, current = 'default' — matches server
✅ Match
hydration succeeds
useEffect fires
setMounted(true) → current = real value from localStorage
React re-renders
normal state update, no error

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() returns theme: undefined on the server — always. The server has no browser, no localStorage, no user context. This is true even if you set defaultTheme in ThemeProvider — that default controls the CSS attribute fallback; it does not populate the JS theme value 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() reads localStorage and returns whatever the user last picked. Server said one thing, client says another — React throws.
  • Fix: a mounted guard. Add const [mounted, setMounted] = useState(false) and useEffect(() => { setMounted(true) }, []). Use mounted ? theme : 'default' instead of theme ?? 'default'. Both passes render the default on first pass — hydration succeeds. After hydration, useEffect fires, mounted flips, 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, Intl locale formatting — anything that differs between server and client must not be read during the initial render. Defer it to useEffect.

The next post in this series covers the useEffect + setState lint rule this fix triggers, and how useSyncExternalStore replaces it entirely.

From useState to useSyncExternalStore for mounted guards

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
← Back to blog