Next.js Hydration Errors from localStorage: 3 Ways to Fix Them

SitePoint TeamPublished inJavaScript·Web·Programming·
October 20, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
How to Fix localStorage Hydration Errors in Next.js
- Identify the localStorage access that runs during server-side rendering or pre-rendering.
- Remove direct localStorage reads from
useStateinitializers and top-level component scope. - Choose a fix:
useEffectdeferral,useSyncExternalStorehook, or a cookie-based approach. - Defer reads with
useEffectfor simple cases where a brief flash of default state is acceptable. - Implement
useSyncExternalStorewith agetServerSnapshotfor reusable, cross-tab-synced localStorage hooks. - Migrate to cookies for above-the-fold preferences like theme or locale that require zero FOUC.
- Verify hydration correctness by running
next build && next startagainst the production build.
Hydration errors caused by localStorage in Next.js remain a commonly reported issue among React developers building with the App Router. The root cause is straightforward but easy to overlook: the server and client disagree on initial state. The right fix depends on trade-offs around flash-of-unstyled-content, SSR correctness, and architectural complexity. This guide evaluates three distinct patterns for resolving these errors: the familiar useEffect approach, the often-overlooked useSyncExternalStore hook from React 18, and a server-readable cookie strategy that eliminates FOUC entirely. Each pattern includes working code and a concrete breakdown of when it fits and when it does not.
This guide targets Next.js 15.x, React 18.x, and Node.js 20.x. All examples use the App Router. The await cookies() API requires Next.js 15 or later; for Next.js 13/14, use cookies() without await. useSyncExternalStore ships natively in React 18; a shim package (use-sync-external-store) covers React 17.
Table of Contents
Why localStorage Breaks Next.js Hydration
Hydration is the process where React, running in the browser, attaches event handlers to server-rendered HTML and reconciles that static markup with the virtual DOM tree the client would have produced. For hydration to succeed, the HTML generated on the server must match, node by node, what the client renders on its initial pass. Any divergence triggers a hydration mismatch error.
The window object does not exist in Node.js. Because localStorage is a property of window, any direct access during server-side rendering fails. Developers hit one of two failure modes. The first is a hard crash: ReferenceError: localStorage is not defined, which kills the server render entirely. In the App Router, 'use client' components still pre-render on the server during next build, so this error fires during server pre-rendering, not in the browser. The second failure mode is subtler and more common: a developer wraps the access in a typeof window !== 'undefined' guard, which avoids the crash but causes the server to render with a default value (say, 'light') while the client reads a stored value (say, 'dark') from localStorage. React detects the difference and throws a hydration mismatch warning. This exact scenario has been reported repeatedly on Stack Overflow, often confusing developers who believe the guard should be sufficient.
The root cause is straightforward but easy to overlook: the server and client disagree on initial state.
The goal here is to compare three patterns that resolve both failure modes, with honest analysis of their trade-offs.
Here is the broken component that triggers the error:
'use client';import{ useState }from'react';exportdefaultfunctionThemeToggle(){const[theme, setTheme]=useState(()=>localStorage.getItem('theme')??'light');return(<buttononClick={()=>{const next = theme ==='light'?'dark':'light';localStorage.setItem('theme', next);setTheme(next);}}>Current theme:{theme}</button>);}Running next build && next start with this component produces either the ReferenceError during server pre-rendering or, if a typeof window check is added to the initializer, a hydration mismatch in production.
What Does “Hydration Mismatch” Actually Mean in Next.js?
Server Render vs. Client Render
During SSR, Next.js runs the React component tree in Node.js to produce an HTML string. Next.js sends that string to the browser, where React builds its own virtual DOM tree and diffs it against the existing server-rendered markup. If the two trees diverge in text content, attribute values, or tree structure, React logs a hydration error. In production, React 18 logs a console error and attempts DOM recovery, but visual glitches, lost state, or broken interactivity may still occur. Development mode adds additional checks and stack traces, making mismatches easier to diagnose.
Why localStorage Is a Common Offender
window.localStorage simply does not exist in the Node.js runtime. Browsers expose it; Node.js does not. The most common anti-pattern is reading it inside a useState initializer:
useState(()=>localStorage.getItem('theme')??'light')Adding a typeof window !== 'undefined' guard prevents the crash, but it introduces an asymmetry. The server always falls back to 'light'. The client reads the user’s stored preference, which might be 'dark'. React sees 'light' on the server and 'dark' on the client, and the mismatch fires. This is not a theoretical concern; it is the exact mechanism behind the majority of localStorage-related hydration errors in Next.js applications.
Fix 1: Post-Mount Effect with useEffect
How It Works
The simplest fix defers the localStorage read until after the component mounts. Server and client both render the same safe default. Once the component mounts in the browser, a useEffect reads the stored value and updates state, triggering a client-only re-render.
'use client';import{ useState, useEffect }from'react';exportdefaultfunctionThemeToggle(){const[theme, setTheme]=useState('light');useEffect(()=>{try{const stored =localStorage.getItem('theme');if(stored ==='light'|| stored ==='dark'){setTheme(stored);}}catch(err){console.warn('[ThemeToggle] Could not read localStorage:', err);}},[]);consttoggle=()=>{const next = theme ==='light'?'dark':'light';try{localStorage.setItem('theme', next);}catch(err){console.error('[ThemeToggle] Failed to write localStorage:', err);}setTheme(next);};return<buttononClick={toggle}>Current theme:{theme}</button>;}The server renders “Current theme: light”. The client also renders “Current theme: light” during hydration, so the trees match. After mount, useEffect fires, reads 'dark' from localStorage if present, and updates the button text. Hydration succeeds.
Pros and Cons
This pattern requires zero dependencies and uses only useState and useEffect, both covered in the official React tutorial. However, it introduces a flash of the default state on first paint, typically lasting one to two frames (under 50 ms on modern hardware, but visible if it triggers a layout shift or color swap). If the user has stored 'dark', they see 'light' for that brief window before the effect runs and triggers a re-render. This FOUC is the primary downside. The pattern also causes two renders on mount: the initial hydration render and the state update from the effect.
Good Fit
This approach works for low-stakes preferences where a brief visual flash is acceptable, such as non-critical UI settings or feature flags that do not affect layout. It also suits prototypes and MVPs where simplicity matters more than polish.
Fix 2: useSyncExternalStore Hook
Why useSyncExternalStore Exists
React 18 introduced useSyncExternalStore specifically to safely subscribe to external datae arguments: a subscribe function, a getSnapshot function for the client, and a getServerSnapshot function that explicitly tells React what value to use during SSR. This third argument eliminates the mismatch by design, because React knows the server snapshot is intentionally different from what the client might read post-mount
Building a Reusable useLocalStorage Hook
Save this hook as hooks/useLocalStorage.js (or .ts) at your project root and import it using Next.js’s built-in @/ alias: import useLocalStorage from '@/hooks/useLocalStorage'.
'use client';import{ useCallback, useRef, useSyncExternalStore }from'react';functiondispatchStorageEvent(key, newValue){window.dispatchEvent(newStorageEvent('storage',{ key, newValue,storageArea:window.localStorage}));}functionuseLocalStorage(key, defaultValue){const defaultRef =useRef(defaultValue);const subscribe =useCallback((callback)=>{if(typeofwindow==='undefined'){return()=>{};}consthandler=(e)=>{if(e.key=== key || e.key===null){callback();}};window.addEventListener('storage', handler);return()=>window.removeEventListener('storage', handler);},[key]);const getSnapshot =useCallback(()=>{try{const stored =localStorage.getItem(key);return stored !==null? stored : defaultRef.current;}catch{return defaultRef.current;}},[key]);const getServerSnapshot =useCallback(()=> defaultRef.current,[]);const value =useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);const setValue =useCallback((newValue)=>{try{localStorage.setItem(key, newValue);dispatchStorageEvent(key, newValue);}catch(err){console.error(`[useLocalStorage] Failed to write key "${key}":`,err);}},[key]);return[value, setValue];}exportdefault useLocalStorage;The subscribe function listens to the native storage event, which fires when another tab modifies localStorage. The native storage event does not fire in the tab that made the change (by browser spec). setValue manually dispatches a synthetic StorageEvent to notify the current tab’s subscriber. The key-check in subscribe (e.key === key) prevents unrelated keys from triggering updates. Treat key as a stable constant; changing key dynamically will cause re-subscription and may briefly return defaultValue. The getServerSnapshot returns the default value during SSR, and getSnapshot reads from localStorage on the client.
The consuming component becomes minimal:
'use client';importuseLocalStoragefrom'@/hooks/useLocalStorage';exportdefaultfunctionThemeToggle(){const[theme, setTheme]=useLocalStorage('theme','light');consttoggle=()=>{setTheme(theme ==='light'?'dark':'light');};return<buttononClick={toggle}>Current theme:{theme}</button>;}Pros, Cons, and When to Reach for It
This pattern is hydration-safe by contract. The getServerSnapshot parameter makes the server value explicit, and React handles the transition without a mismatch error. Cross-tab synchronization comes for free from the server snapshot, React schedules a client re-render after hydration. FOUC still occurs; useSyncExternalStore’s advantage is an explicit hydration contract and built-in cross-tab sync, not flash elimination
FOUC still occurs; useSyncExternalStore’s advantage is an explicit hydration contract and built-in cross-tab sync, not flash elimination.
The downsides: the server snapshot still differs from the stored value, so users see a flash of the default on first paint, just as with useEffect. The hook also requires implementing subscribe, getSnapshot, and getServerSnapshot callbacks, and developers unfamiliar with useSyncExternalStore may find its subscription model harder to reason about.
This is the right choice for any application that treats localStorage as a live, subscribable store. Multi-tab dashboards, collaborative tools, and apps with many components reading the same stored value all benefit from the built-in synchronization. It also works well as a reusable hook consumed across many components, because the API returns a [value, setter] tuple matching the useState contract and the hydration behavior is explicit.
Fix 3: Move the
Why Cookies Solve the Flash Problem
Cookies travel with every HTTP request. In the Next.js App Router, server components and layouts can read cookies from next/headers. This means the server has access to the user’s preference before rendering a single byte of HTML. The server renders the correct UI on the very first paint, and the client hydrates against that same value. No FOUC
Implementation Walkthrough
import{ cookies }from'next/headers';importThemeTogglefrom'./components/ThemeToggle';exportdefaultasyncfunctionRootLayout({ children }){const cookieStore =awaitcookies();const theme = cookieStore.get('theme')?.value ??'light';return(<htmllang="en"data-theme={theme}><body><ThemeToggleinitialTheme={theme}/>{children}</body></html>);}'use client';import{ useState }from'react';import{ useRouter }from'next/navigation';functionsetCookie(name, value, days =365){if(typeofwindow==='undefined'){thrownewError('setCookie must only be called in a browser context');}if(days <=0){thrownewRangeError('days must be a positive number');}const encodedName =encodeURIComponent(name);const encodedValue =encodeURIComponent(value);const expires =newDate(Date.now()+ days *24*60*60*1000).toUTCString();const secureFlag =window.location.protocol==='https:'?';Secure':'';document.cookie=`${encodedName}=${encodedValue};expires=${expires};path=/;SameSite=Lax${secureFlag}`;}exportdefaultfunctionThemeToggle({ initialTheme }){const[theme, setTheme]=useState(initialTheme);const[isRefreshing, setIsRefreshing]=useState(false);const router =useRouter();consttoggle=async()=>{if(isRefreshing)return;const next = theme ==='light'?'dark':'light';try{setIsRefreshing(true);setCookie('theme', next);setTheme(next);router.refresh();}catch(err){console.error('[ThemeToggle] Failed to set theme cookie:', err);setTheme(theme);}finally{setIsRefreshing(false);}};return(<buttononClick={toggle}disabled={isRefreshing}>Current theme:{theme}</button>);}The router.refresh() call triggers a server-side re-render of the current route, picking up the new cookie value. Note:router.refresh() makes a server request on every call. For high-frequency updates, prefer a Server Action that sets the cookie via cookies().set() from next/headers, avoiding the full client-side refresh. Set cookie duration to match your privacy policy. For purely functional preferences, 365 days is common, but verify compliance with applicable regulations (e.g., GDPR, ePrivacy Directive).
Pros and Cons
This pattern achieves zero flash: server and client agree from the very first byte, so there is no divergence to reconcile. However, cookies max out at roughly 4 KB each, making them unsuitable for large payloads. The approach also requires attention to cookie attributes: SameSite and path must be set correctly. The Secure flag applies automatically when the page loads over HTTPS (including production); on http://localhost during development, the flag is omitted to prevent the cookie from being silently dropped. This pattern adds more infrastructure than the client-only approaches, whether through Server Actions, API routes, or manual document.cookie management. Cookies set via document.cookie are JS-accessible (not HttpOnly), which is intentional here since the client needs to write the value.
Cookies are the right choice for theme, locale, or any preference that affects above-the-fold rendering. When eliminating FOUC is a hard product requirement, cookies are the only pattern among these three that delivers.
Comparison Table: Which Fix Should You Choose?
| Criteria | useEffect | useSyncExternalStore | Cookies |
|---|---|---|---|
| Hydration-safe | Yes | Yes | Yes |
| Flash of default (FOUC) | Yes | Yes | No |
| SSR-correct first paint | No | No | Yes |
| Cross-tab sync | Manual | Built-in | On reload/navigation |
| Data size limit | ~5 MB (browser-dependent) | ~5 MB (browser-dependent) | ~4 KB per cookie |
| Complexity | Low | Medium | Medium to High |
| Best for | Low-stakes defaults | Reusable store hook | Critical UI preferences |
There is no single winner. The right fix depends on whether FOUC matters to the product, how many components consume the stored value, and how much data you persist. Most applications benefit from starting with useSyncExternalStore for its explicit hydration contract, [value, setter] API, and built-in cross-tab sync, then migrating specific high-visibility preferences to cookies when the flash becomes unacceptable.
Common Pitfalls and Debugging Tips
Don’t Conditionally Render Entire Component Trees
Wrapping components in if (typeof window !== 'undefined') causes tree-shape mismatches. The server renders nothing; the client renders the full subtree. React cannot reconcile a missing node with a present one, producing hydration errors that are harder to diagnose than value mismatches.
Use suppressHydrationWarning Sparingly
The suppressHydrationWarning prop is appropriate for content that is inherently different between server and client, such as timestamps or CSRF tokens. You can also apply it to <html> or <body> when browser extensions modify attributes outside React’s control. Using it to suppress localStorage-related mismatches masks real bugs and can lead to silent UI inconsistencies in production.
Verify with next build and next start
Always test hydration behavior by running next build && next start against the production build. In production, React 18 logs a console error and attempts DOM recovery, but visual glitches may occur. Development mode adds additional checks and stack traces, making it easier to spot issues but also more lenient in some cases.
When eliminating FOUC is a hard product requirement, cookies are the only pattern among these three that delivers.
Start with useSyncExternalStore for most localStorage-backed state; move to cookies when the stored preference affects above-the-fold rendering and FOUC is unacceptable. For further reference, consult the Next.js documentation on hydration errors and the React documentation on useSyncExternalStore.
Sharing our passion for building incredible internet things.


