React browser() API: Fix localStorage Errors in Next.js 16.4
Your Next.js build stops at "Generating static pages" with ReferenceError: localStorage is not defined, and the stack points at a useState initializer in a component that already starts with 'use client'. Or you added a typeof window guard, the build passed, and now React complains that the server rendered text didn't match the client. Same root cause. React 19.3 added a direct fix for it, the React browser API (use(browser())), and Next.js 16.4 ships it to every App Router app.
Keep the component 'use client', call use(browser('why it is browser-only')) at the top, and wrap it in <Suspense fallback={...}> in the page. browser comes from react-dom 19.3, which Next.js 16.4 ships. On the server React stops at that call and sends the fallback. The browser renders the real component, so there's no ReferenceError and no hydration mismatch. Leave out the Suspense boundary and next build fails.
Why is localStorage not defined in Next.js even with 'use client'?
Because Client Components are still prerendered on the server: 'use client' doesn't mean "browser only". Their render function runs once in Node during next build (or on request), and Node has no browser storage to read. A useState(() => localStorage.getItem('theme')) initializer runs during that render. So does anything in the component body, like Intl.DateTimeFormat().resolvedOptions().timeZone, which won't throw but will quietly return the server's time zone.
The directive only marks where the client bundle starts. Server Components never ship their code to the browser, Client Components do, and both kinds produce HTML on the server first. If that split is still fuzzy, I wrote up how React Server Components work in Next.js separately.
There's a twist on newer Node versions. Node 25 has a global localStorage object of its own, behind Web Storage support. Without a --localstorage-file path it has no working methods, so on Node 25 the same bug shows up as TypeError: localStorage.getItem is not a function plus a warning about --localstorage-file. Searching for the classic message won't find that one. The hands-on section has both outputs.
The old fixes all work around the same fact. The Next.js hydration error docs still list three: a useEffect plus an isClient state, next/dynamic with ssr: false, and suppressHydrationWarning, which "only works one level deep". That page doesn't mention browser() yet.
Why the typeof window guard trades one error for a hydration mismatch
A guard like this fixes the crash by rendering something different on each side. On the server, typeof window is 'undefined', so the component renders the default (light, UTC). In the browser, the first render reads the saved value (dark, the visitor's real zone). React hydrates by comparing that first browser render with the server HTML, finds different text, and throws the server's markup away for that tree.
The Next.js docs name this pattern directly as a cause of hydration errors: "Using checks like typeof window ... in your rendering logic". React 19.3.1 lists it first in the error message too, as you'll see in my output below. The build passing is what makes this version sneaky. Nothing fails in CI. Only users with a saved value different from the default ever see the error, and they see the content flicker.
What you actually want is simpler: don't render this component on the server at all, show a placeholder there, and let the browser render it once with the real values. Until now that meant a mounted flag or next/dynamic. React 19.3 makes it a one-line call.
How the React browser API works: use(browser()) inside Suspense
You call it like a hook, through use. The React 19.3 release post (September 9, 2026) describes it as triggering Suspense "on the server, but not in the client", and the browser() reference page gives the details:
import { use } from 'react';
import { browser } from 'react-dom';
// inside a Client Component, pick one form:
use(browser()); // no reason
use(browser('Reads the saved theme.')); // reason as a string
use(browser(() => new Error('Needs the visitor time zone.'))); // reason built lazily
- On the server,
use(browser())stops rendering the component and leaves the closest<Suspense>boundary's fallback in its place. - In the browser, it returns
undefinedand the component renders normally, so code below the call can readlocalStorage,windowor the visitor's time zone. - The reason is optional. A string, or the return value of a function, becomes the
causeof the error passed to the server'sonBrowserBailoutcallback. React only calls a reason function on the server, so use one when building the message costs something. - No recoverable errors. When React leaves a fallback for the browser this way, it doesn't call the server's
onErroror the client'sonRecoverableError. That's the difference from the old trick of throwing inside Suspense on the server, which showed up as React error #419.
Three rules from the docs bite in practice. It must be inside a <Suspense> boundary during server rendering, or the server render fails. It must run in a Client Component. And calling browser() on its own does nothing: pass the value to use, never throw it. The implementation PR, react/react #37143, explains the first rule in one line: "you cannot recover from the root."
One detail I like: like any use call, it can sit after an early return or inside a condition. The reference page's useBrowserQuery example only opts out of server rendering when there's no initialData. So a component can render fully on the server when it has the data, and bail out to the browser only when it doesn't. A mounted flag can't do that cleanly. If you're used to the strict rules of hooks, this is the same exception that use already had.
Fixing the localStorage component in a Next.js 16.4 App Router page
Two files change. The component gets the use(browser()) call at the top, before the useState that reads storage. This is the exact file from my lab app:
'use client';
import { use, useState } from 'react';
import { browser } from 'react-dom';
export default function Prefs() {
use(browser('Theme and time zone are stored in the browser.'));
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light');
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
function toggle() {
const next = theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', next);
setTheme(next);
}
return (
<section>
<p>Theme: <strong>{theme}</strong></p>
<p>Your time zone: <strong>{timeZone}</strong></p>
<button onClick={toggle}>Toggle theme</button>
</section>
);
}
The page stays a Server Component and wraps only that component in Suspense:
import { Suspense } from 'react';
import Prefs from './prefs';
export default function Home() {
return (
<main>
<h1>Account settings</h1>
<p>This heading is rendered on the server.</p>
<Suspense fallback={<p>Loading your preferences…</p>}>
<Prefs />
</Suspense>
</main>
);
}
Put the boundary as close to the browser-only component as you can. The fallback is the HTML that every visitor and every crawler gets first. The Next.js single-page apps guide spells out the SEO side: crawlers that don't execute JavaScript "see the fallback instead of the component", so indexable content belongs outside the boundary. In my test, an app/loading.tsx also counted as a boundary, but it turned the entire page into the fallback, heading included.
No config change is needed. browser resolves to the React that Next bundles for the App Router, and @types/react-dom 19.3.0 already types it as browser(reason?: string | (() => unknown)): BrowserUsable. If you know Suspense mainly from code splitting, my post on React Suspense and lazy loading covers how fallbacks behave on the client.
I broke and fixed it on a fresh Next.js 16.4.5 app (Node 25)
My setup: a Linux server with Node v25.2.1 and npm 11.6.2, and an app from create-next-app@16.4.5 with TypeScript and the App Router. It pinned react and react-dom to 19.3.0, not the 19.3.1 that npm tags as latest, and it turned Cache Components on by default (cacheComponents: true, partialPrefetching: true). I swapped the generated layout for a minimal one without next/font/google so the build wouldn't download fonts. Every build below is Turbopack.
First I checked which React the App Router really runs:
$ node -p "require('next/dist/compiled/react-dom/package.json').version"
undefined
$ grep -o 'exports.version = "[^"]*"' node_modules/next/dist/compiled/react-dom/cjs/react-dom.production.js
exports.version = "19.3.0-canary-278794d7-20261002"
So the react-dom in my package.json isn't what renders the page. Next 16.4.5 uses its own vendored canary build, and that build exports browser.
Failure 1: the build crashes, with a Node 25 twist
The starting component read storage in the useState initializer, exactly like the fixed version above but without the use(browser()) line. I expected the famous ReferenceError. npx next build gave me this instead:
Generating static pages using 1 worker (0/4) ...
(node:520228) Warning: `--localstorage-file` was provided without a valid path
(Use `node --trace-warnings ...` to show where the warning was created)
Generating static pages using 1 worker (1/4)
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: localStorage.getItem is not a function
at <unknown> (app/prefs.tsx:6:57)
at <unknown> (app/prefs.tsx:6:29)
4 |
5 | export default function Prefs() {
> 6 | const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light');
| ^
...
Export encountered an error on /page: /, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
Node 25 defines localStorage globally, but without --localstorage-file its methods are missing. A quick check confirmed it:
$ node -e "console.log(typeof localStorage, typeof globalThis.localStorage?.getItem)"
object undefined
(node:520242) Warning: `--localstorage-file` was provided without a valid path
(Use `node --trace-warnings ...` to show where the warning was created)
$ node --no-webstorage -e "console.log(typeof localStorage)"
undefined
With NODE_OPTIONS=--no-webstorage npx next build I got the message most people search for:
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
ReferenceError: localStorage is not defined
at <unknown> (app/prefs.tsx:6:77)
at <unknown> (app/prefs.tsx:6:29)
...
Export encountered an error on /page: /, exiting the build.
I wouldn't ship --no-webstorage as a fix. It only swaps the error message, and the component still renders in Node during prerender.
Failure 2: the typeof window guard builds and lies
Next I guarded both reads with typeof window and fell back to 'light' and 'UTC'. The build passed with / as ○ (Static). Then I ran next start on port 3055 and curled the page:
<main><h1>Account settings</h1><p>This heading is rendered on the server.</p><section><p>Theme: <strong>light</strong></p><p>Your time zone: <strong>UTC</strong></p><button>Toggle theme</button></section></main>
The server hard-codes light and UTC for everyone. There's no browser on that server (headless Chromium was missing its shared libraries), so I reproduced the browser half with React 19.3.1 and jsdom 29.1.1 in a small script, hydrate.mjs. It server-renders the same guarded component, then hydrates the HTML in jsdom with theme set to dark in storage:
SERVER HTML: <main><h1>Account settings</h1><p>Theme: <strong>light</strong></p></main>
[onRecoverableError] Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
...
<strong>
+ dark
- light
CLIENT DOM: <main><h1>Account settings</h1><p>Theme: <strong>dark</strong></p></main>
The same script with use(browser()) inside Suspense printed no onRecoverableError at all, and the client DOM showed dark straight away.
The fix: still static, fallback in the HTML
With the two files from the previous section in place, the build went through and the route stayed static, even with Cache Components on:
✓ Generating static pages using 1 worker (4/4) in 273ms
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ ./serve-curl.sh / curl-3-browser.html
$ grep -o '<main>.*</main>' snapshots/curl-3-browser.html
<main><h1>Account settings</h1><p>This heading is rendered on the server.</p><!--$!--><template data-dgst=""></template><p>Loading your preferences…</p><!--/$--></main>
$ grep -c "Theme and time zone" snapshots/curl-3-browser.html
0
The heading is real server HTML. The preferences are replaced by the fallback inside a client-rendered Suspense marker, and the reason string isn't serialized into the page. next start logged nothing about the bailout. I also grepped .next/static/chunks to see whether skipping the server render keeps the code out of the browser. It doesn't. Both "Toggle theme" and the reason string were in the client chunk 2un8o9avykp85.js.
Removing Suspense: TypeScript is happy, the build isn't
I deleted the <Suspense> wrapper from page.tsx and made sure there was no loading.tsx:
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.
at <unknown> (app/prefs.tsx:7:3)
5 |
6 | export default function Prefs() {
> 7 | use(browser('Theme and time zone are stored in the browser.'));
| ^
...
digest: '1911750590',
[cause]: 'Theme and time zone are stored in the browser.'
}
Export encountered an error on /page: /, exiting the build.
Look at the last lines: the reason comes back as [cause]. That alone is a good argument for always writing one. Adding an app/loading.tsx instead of the explicit boundary made the build pass, but the curl showed only Loading page… in the body. The <h1> existed only inside the RSC payload script, not as markup.
Forgetting 'use client'
Calling it from a Server Component (app/rsc/page.tsx, no directive) also passed TypeScript and failed at prerender:
Error occurred prerendering page "/rsc". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: (0 , d.browser) is not a function
at e (app/rsc/page.tsx:5:7)
3 |
4 | function ServerClock() {
> 5 | use(browser('Needs the visitor time zone.'));
| ^
The message never says "Server Component". With next build --debug-prerender the minified name became (0 , __TURBOPACK__imported__module__75465__.browser) is not a function, which at least points at the import. The react-server build of react-dom simply doesn't have browser.
My own tooling tripped me up once, too. The serve-curl.sh helper killed the server and then ran wait, which returned the killed process's non-zero status and silently skipped the next command in an && chain. Adding || true to that line fixed it.
use(browser()) vs mounted flag vs next/dynamic ssr: false
Pick by what you need from the server HTML and from the bundle. For comparison, the mounted flag is the classic useEffect pattern from the hydration docs:
'use client';
import { useEffect, useState } from 'react';
export default function PrefsMounted() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
if (theme === null) return <p>Loading your preferences…</p>;
return <p>Theme: <strong>{theme}</strong></p>;
}
For next/dynamic I built a second route in the lab, app/dyn/page.tsx, marked 'use client', loading a PrefsLegacy component with dynamic(() => import('../prefs-legacy'), { ssr: false, loading: () => <p>Loading your preferences…</p> }). Here's how the three compare:
use(browser()) + Suspense | Mounted flag | next/dynamic ssr: false | |
|---|---|---|---|
| Server HTML for the widget | Suspense fallback in a client-rendered boundary | Your placeholder, as normal markup | Same client-rendered boundary markup as browser() (in 16.4.5) |
| Renders in the browser | Once, with real values | Twice: placeholder, then real values after the effect | Once the lazy chunk has loaded |
| Needs a Suspense boundary | Yes, or the build fails | No | No, the loading option is the fallback |
| Allowed in a Server Component file | No, TypeError at prerender | No, hooks need a Client Component | No, Turbopack build error |
| Code-splitting | No, chunk loaded with the page | No | Yes, chunk loaded on demand |
| Conditional opt-out | Yes, can follow an early return | Possible, but you manage it by hand | No, decided per import |
The first row surprised me. Curling /dyn and / after the same build gave identical boundary markup, an empty <template data-dgst=""> followed by the fallback. The changelog explains why: Next.js 16.4.0 replaced its old CSR bailout error for ssr: false with ReactDOM.browser (vercel/next.js #96826), and #98439 turned experimental.reactBrowserBailout on by default. The useSearchParams bailout moved to the same mechanism in #96843.
So the real difference left is the bundle. In my build, the use(browser()) chunk was referenced from the / HTML and loaded upfront. The next/dynamic chunk wasn't referenced from the /dyn HTML at all. It loads on demand. For a small preferences widget that doesn't matter. For a map library or a rich text editor it does, and I'd still use next/dynamic there. Moving ssr: false into a Server Component fails the build with `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component., same as before 16.4. For more on keeping heavy client code out of the first load, see my React performance notes.
Logging bailouts with the reason argument and onBrowserBailout
The reason string does two jobs. It documents in the code why a component skips the server, and it's the cause React reports to onBrowserBailout, a server-renderer option added in react/react #37193. Next.js owns its renderer, so I tested the callback in plain react-dom/server 19.3.1:
// Log browser-only bailouts with onBrowserBailout. Usage: node bailout.mjs [--no-suspense]
import { createElement as h, Suspense, use } from 'react';
import { browser } from 'react-dom';
import { renderToPipeableStream } from 'react-dom/server';
import { Writable } from 'node:stream';
const withSuspense = !process.argv.includes('--no-suspense');
function Draft() {
use(browser('Draft text lives in localStorage.'));
return h('textarea', { defaultValue: localStorage.getItem('draft') ?? '' });
}
function TimeZone() {
use(browser(() => new Error('Needs Intl time zone of the visitor.')));
return h('p', null, Intl.DateTimeFormat().resolvedOptions().timeZone);
}
function App() {
const body = h('div', null, h(Draft), h(TimeZone));
return h('main', null,
h('h1', null, 'Editor'),
withSuspense ? h(Suspense, { fallback: h('p', null, 'Loading editor…') }, body) : body
);
}
let html = '';
const sink = new Writable({
write(chunk, _enc, cb) { html += chunk; cb(); },
final(cb) { console.log('HTML:', html); cb(); },
});
const { pipe } = renderToPipeableStream(h(App), {
onBrowserBailout(error, errorInfo) {
console.log('[onBrowserBailout] message:', error.message);
console.log('[onBrowserBailout] cause:', error.cause);
console.log('[onBrowserBailout] componentStack:', errorInfo.componentStack);
},
onError(error) {
console.log('[onError] message:', error.message);
console.log('[onError] cause:', error.cause);
},
onShellError(error) {
console.log('[onShellError]', error.message);
},
onAllReady() { pipe(sink); },
});
[onBrowserBailout] message: Browser-only rendering was requested by `browser()`.
[onBrowserBailout] cause: Draft text lives in localStorage.
[onBrowserBailout] componentStack:
at Draft (file:///opt/blog-pipeline/runs/2026-11-05/lab/plain-react/bailout.mjs:10:3)
at div (<anonymous>)
at Suspense (<anonymous>)
at main (<anonymous>)
at App (<anonymous>)
HTML: <main><h1>Editor</h1><!--$!--><template data-dgst=""></template><p>Loading editor…</p><!--/$--></main>
Two components bailed out inside one boundary, and the callback fired once, for Draft. That matches the PR: one report per boundary, first error wins. onError stayed silent. In the development build the <template> also carried data-msg and data-cstck attributes with a component stack, while production left it empty, like Next's output. Run with --no-suspense, the bailout went to onError and onShellError with the same "outside a Suspense boundary" message Next printed, and onBrowserBailout wasn't called.
Next 16.4.5 passes its own onBrowserBailout to React in app-render.js, for its dynamic rendering validation. I grepped next/dist and found no config key or hook that forwards bailouts to app code. The only related setting is experimental.reactBrowserBailout, which defaults to true. Write good reason strings anyway: they show up as [cause] in build failures.
When I wouldn't reach for browser()
Content you want indexed. Anything inside the boundary is a placeholder for crawlers and for the first paint. Product text, headings and prices shouldn't depend on localStorage. Render them on the server and keep only the personal bits browser-only.
Values the server can know. If the theme lives in a cookie, read it on the server and render the right thing. That beats any browser-only trick, because the first HTML is already correct and nothing flickers.
Reads that don't affect the first render. Saving a draft in an onChange handler, or syncing a value in an effect, never runs on the server. No bailout needed. The Next.js guide puts the rule well: use it "only when a Client Component's first browser render cannot match server-rendered HTML."
Heavy browser-only libraries. For an editor, a map or a charting library I'd keep next/dynamic with ssr: false, because in 16.4 it gives the same bailout and keeps the chunk lazy.
Before you ship, check that next build passes (that proves a boundary exists), the curled HTML shows a fallback you'd be fine with Google indexing, and every browser() call has a reason a teammate would understand from a build log. The InfoQ write-up also notes that the use reference still listed browser() as Canary-only in October, so don't let the docs banner scare you off.
FAQ
Is React browser() stable or still canary only?
It's stable. React 19.3, announced on September 9, 2026, ships browser() in react-dom, and react-dom 19.3.1 on npm exports it. Parts of react.dev still labeled it Canary-only in October, so the docs lag behind the release. In a Next.js App Router app it matters even less, because Next uses its own bundled copy of React.
Which React version does Next.js 16.4 actually use?
The App Router runs the React that Next bundles, not the one in your package.json. In next 16.4.5 the vendored react-dom reports 19.3.0-canary-278794d7-20261002, while create-next-app 16.4.5 pinned react and react-dom 19.3.0 in my lab app. You can check it by grepping exports.version in node_modules/next/dist/compiled/react-dom/cjs/react-dom.production.js.
Does use(browser()) hurt SEO?
It can, for whatever sits inside the boundary. The Suspense fallback becomes the initial HTML, so crawlers that don't run JavaScript see the fallback instead of the component, and the Next.js docs say so directly. My curl of the built page showed exactly that. Keep headings, copy and anything you want indexed outside the browser-only boundary, and make the fallback text meaningful.
Should every localStorage read use browser()?
No. The Next.js docs say to use it only when a Client Component's first browser render can't match the server HTML. If you read localStorage only in an event handler or an effect, nothing differs at render time and you don't need it. Reach for browser() when the stored value decides what the component shows on its very first render.
Does use(browser()) replace the 'use client' directive?
No, you need both. browser() has to be called from a Client Component, and it doesn't exist in the react-server build that Server Components use. When I called it in a file without 'use client', next build failed with TypeError: (0 , d.browser) is not a function, even though TypeScript passed. The directive marks the client boundary. browser() decides where server rendering stops.