React 19.3 use(browser()): render browser-only components without hydration errors or useEffect hacks
Some components can't render correctly on the server: the user's time zone, something saved in localStorage, the window size, a feature check. If they render anyway, the server's HTML and the browser's first render disagree, and React reports a hydration error. Until now, the usual fix was a useEffect that flips a mounted flag, or next/dynamic with ssr: false in Next.js.
React 19.3 (September 9, 2026) adds a first-class API for this: browser() from react-dom. Everything below was tested with [email protected], using a small renderToPipeableStream server running with TZ=UTC and the page loaded in headless Chromium 153, Firefox 155 and WebKit 26.6 (official Playwright Docker image) with the browser's time zone set to Asia/Tokyo. That way, server and browser are guaranteed to disagree. It was also tested in Next.js 16.3.8.
The problem: reading the browser during render
function TimeZone() {
return <b>{Intl.DateTimeFormat().resolvedOptions().timeZone}</b>;
}The server rendered UTC, the browser rendered Asia/Tokyo, and all three engines logged:
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: …React recovers by throwing away the server HTML for that tree and rendering it again in the browser. The page works, but you pay for rendering it twice, and the error ends up in your monitoring.
The fix: use(browser())
'use client';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
function TimeZone() {
use(browser('Time zone is only known in the browser'));
return <b>{Intl.DateTimeFormat().resolvedOptions().timeZone}</b>;
}
export function TimeZoneLabel() {
return (
<p>
Your time zone:{' '}
<Suspense fallback={<i>Loading…</i>}>
<TimeZone />
</Suspense>
</p>
);
}On the server, use(browser()) stops rendering the component, and React sends the nearest <Suspense> fallback instead. In the browser, it returns undefined and the component renders normally. The string argument is optional and only used for server-side reporting (more on that below). Note that you pass the result to use(). Calling browser() on its own does nothing.
The server sent Your time zone: Loading…. In the browser, with timings from a MutationObserver and render logging, the same in all three engines apart from the exact milliseconds:
chromium: 12ms fallback shown → 71ms App hydrated → 72ms TimeZone render → 80ms fallback removed → 81ms TimeZone committed
firefox: 17ms fallback shown → 76ms App hydrated → 77ms TimeZone render → 87ms fallback removed → 88ms TimeZone committed
webkit: 6ms fallback shown → 35ms App hydrated → 36ms TimeZone render → 39ms fallback removed → 41ms TimeZone committed
console errors: noneThe rest of the page hydrated from the server HTML. TimeZone rendered once, right after hydration, and replaced the fallback a few milliseconds later. There were no hydration errors.
Compared with the useEffect "mounted" trick
function TimeZone() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <b>{mounted ? Intl.DateTimeFormat().resolvedOptions().timeZone : '…'}</b>;
}This also avoids the hydration error. Server and first browser render both show …, then an effect triggers a second render:
73ms TimeZoneEffect render (mounted=false) → 75ms App hydrated → 76ms TimeZoneEffect render (mounted=true)The difference shows up when the component is mounted after the page has loaded, for example behind a button. With use(browser()) there's nothing to wait for, so it rendered immediately with the real value, and the fallback never appeared. The useEffect version renders its placeholder first even then, because it can't tell a hydration from a normal mount:
use(browser()): 931ms TimeZone render → 942ms TimeZone committed
useEffect trick: 931ms TimeZoneEffect render (mounted=false) → 938ms TimeZoneEffect render (mounted=true)It also changes how you write the component. With browser(), the browser-only code runs during render like any other code. You can read localStorage in a useState initializer, with no effect that copies a value into state after the first render:
function SavedDraft() {
use(browser('The draft is stored in localStorage'));
const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}With Unsent message from yesterday stored in localStorage, the server sent the Loading draft… fallback, and the textarea contained the saved text from its very first render in the browser.
Rule 1: there must be a Suspense boundary
browser() needs a <Suspense> boundary above the component, because the fallback is what the server renders instead. Without one, the server render fails:
onShellError: The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.With renderToPipeableStream, the whole page became a 500. In Next.js, a statically prerendered page fails the build, pointing at the line:
Error occurred prerendering page "/nosuspense". 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/TimeZone.tsx:6:3)
> 6 | use(browser('Time zone is only known in the browser'));
[cause]: Error: Minified React error #603
…
⨯ Next.js build worker exited with code: 1 and signal: nullError #603 is Browser-only rendering was requested by `browser()`. Put the boundary as close to the component as you can: everything inside it is replaced by the fallback on the server.
Rule 2: Client Components only
In React Server Components, browser doesn't exist: the server build of react-dom doesn't export it. Calling it in a Next.js Server Component failed the build with:
Error occurred prerendering page "/server-misuse".
TypeError: (0 , d.browser) is not a function
at <unknown> (app/server-misuse/page.tsx:5:7)The component that calls use(browser()) must be in a 'use client' module. The <Suspense> around it can be in a Server Component, as in the Next.js example below. "Client Component" doesn't mean "browser only": Client Components are still rendered on the server, which is exactly why this API exists.
Only skip the server when you have to
use(), unlike other hooks, can be called conditionally, so a component can render on the server when it has what it needs, and opt out only when it doesn't:
function useTimeZone(defaultTimeZone) {
if (defaultTimeZone !== undefined) return defaultTimeZone;
use(browser('No default time zone provided'));
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}<TimeZoneWithDefault defaultTimeZone="Europe/Warsaw" /> → server HTML: Europe/Warsaw (no fallback, no browser render)
<TimeZoneWithDefault /> → server HTML: Loading… → browser: Asia/TokyoFor example, if you store the user's time zone in a cookie after the first visit, the server can read it and render the real value, and only first-time visitors get the browser-only path.
Server side: onBrowserBailout and timeouts
renderToPipeableStream has a new onBrowserBailout callback, called for every boundary that switched to browser rendering. The string you passed to browser() arrives as error.cause:
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
stream.pipe(res);
},
onBrowserBailout(error, errorInfo) {
console.log(`onBrowserBailout: cause=${JSON.stringify(error.cause)} | ${errorInfo.componentStack.trim().split('\n')[0].trim()}`);
},
});onBrowserBailout: cause="Time zone is only known in the browser" | at TimeZone (file:///w/dist/app.js:17:3)
onBrowserBailout: cause="The draft is stored in localStorage" | at SavedDraft (file:///w/dist/app.js:31:3)Use it to measure how much of a page actually renders on the server. The reason can also be a function, which React calls only on the server, useful if building it is expensive.
You can also pass browser() to abort(), to give up on slow parts of the server render and let the browser finish them. Boundaries that are still pending get onBrowserBailout instead of onError:
setTimeout(() => stream.abort(browser('Server render timed out after 500ms')), 500);onBrowserBailout: cause="Server render timed out after 500ms" | at SlowStats (file:///w/dist/app.js:38:38)Keep the timeout short, though. The browser can't start rendering that boundary until the server sends the abort, so the timeout delays the content in the browser too. With a 500 ms timeout:
chromium: 11ms fallback shown → 68ms App hydrated → 514ms fallback removed
firefox: 11ms fallback shown → 53ms App hydrated → 513ms fallback removed
webkit: 519ms fallback shown → 557ms App hydrated → 558ms fallback removedChromium and Firefox showed the fallback and hydrated the rest of the page right away. WebKit didn't paint anything until the stream finished.
In Next.js 16.3
Next.js ships its own copy of React, and Next.js 16.3.8's copy (19.3.0-canary-cbb046ab-20260731) already exports browser. The TimeZone component above worked unchanged, with the Suspense boundary in a Server Component page:
// app/page.tsx (Server Component)
import { Suspense } from 'react';
import { TimeZone } from './TimeZone'; // 'use client' + use(browser())
export default function Page() {
return (
<p id="out">
Your time zone:{' '}
<Suspense fallback={<i>Loading…</i>}>
<TimeZone />
</Suspense>
</p>
);
}The page was still prerendered as static. The generated HTML contains a client-rendered Suspense boundary (<!--$!-->) with the fallback inside, which is exactly what next/dynamic with ssr: false produces. The only difference is the marker: next/dynamic uses an internal BAILOUT_TO_CLIENT_SIDE_RENDERING error:
use(browser()): <!--$!--><template data-dgst=""></template><i>Loading…</i><!--/$-->
next/dynamic ssr: false: <!--$!--><template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING"></template><i>Loading…</i><!--/$-->In Chromium, both pages showed Your time zone: Asia/Tokyo with no console errors or warnings. The next/dynamic page loaded 7 scripts instead of 6, because next/dynamic also splits the component into its own chunk:
useEffect + mounted | next/dynamic, ssr: false | use(browser()) | |
|---|---|---|---|
| Hydration error | no | no | no |
| Renders in the browser | twice, even when mounted later | once | once |
| Placeholder | you render it yourself | loading option | nearest <Suspense> fallback |
| Extra JS request | no | yes (separate chunk) | no |
| Works outside Next.js | yes | no | yes (React 19.3) |
| Conditional / in a custom hook | awkward | no | yes |
| Server reporting | no | no | onBrowserBailout |
next/dynamic is still useful when you also want to load a heavy component lazily. For "don't render this on the server", use(browser()) is now the simpler tool.
When not to use it
- Content that should be indexed or shown immediately. The server HTML contains only the fallback, so search engines, link previews and users on slow connections see
Loading…until the JavaScript runs. Keep it to small, user-specific bits. - Values the server can know. Locale, time zone, theme and "logged in" state can often come from cookies or headers. Rendering them on the server is better than any client-side fallback.
- Large parts of the page. Everything inside the boundary disappears from the server HTML. Put the boundary tightly around the browser-only part.
And if you're rendering HTML yourself, declare the charset. During this test, a server response without charset=utf-8 turned the … in a placeholder into mojibake, which produced a hydration error that had nothing to do with the component. Firefox was the only browser that warned about the missing encoding.
Sources & further reading
- React 19.3 release post
- React docs: browser (caveats,
onBrowserBailout, aborting) - React docs: use
- React docs: Suspense
- React docs: renderToPipeableStream
- React docs: hydrateRoot (hydration mismatches)
- Next.js docs: lazy loading (
next/dynamic,ssr: false)