React 19.3 browser(): Replace Mounted Flags in SSR

Published on 9/25/2026•By Prakhar Bhatia
React 19.3 browser(): Replace Mounted Flags in SSR

React 19.3 gives server-rendered applications a first-class way to say that a component can only produce meaningful UI in a browser. Call use(browser()) inside a Client Component, place it under <Suspense>, and React leaves the fallback in the server HTML. During hydration, the component renders normally with access to localStorage, the device time zone, layout measurements, or a browser-side data cache.

This replaces a familiar pile of workarounds: mounted state set in an effect, typeof window !== 'undefined' branches, and framework-specific SSR opt-outs used only to avoid a mismatch. Those patterns are not universally wrong. React's new API gives the choice an explicit rendering meaning and integrates it with Suspense.

The important decision remains architectural. Most components should still render useful, matching HTML on the server. Browser-only rendering is an escape hatch for the small parts of an interface whose initial value genuinely exists only on the device.

The Problem React Is Solving

Server rendering asks one component tree to run in two environments with different capabilities.

The server produces the initial HTML

The server has request headers, route parameters, backend credentials, and data it can fetch before sending a response. It does not have the user's localStorage, viewport size, browser extension state, media devices, or current DOM layout.

React sends HTML so the user can see content before all client JavaScript loads. The browser then hydrates that markup by attaching React behavior to the existing DOM.

The first client render must match

hydrateRoot expects the client tree to produce the same initial markup as the server. React documents mismatches as bugs. Development builds warn, and React does not promise to patch every differing attribute because validating all markup would add cost to the common matching case.

A component that reads the current hour on the server and the user's local hour in the browser can disagree. The same problem appears with random IDs, stored preferences, URL rewrites, device capabilities, and caches that exist only on the client.

Some UI has no honest server value

A saved draft editor can render an empty textarea on the server, but the user's actual draft may already be in localStorage. A map library may access window at module initialization. A tooltip may need element geometry before it can choose a position.

The server can guess, delay, or show a placeholder. browser() formalizes the third option: keep the nearest Suspense fallback in the HTML and let the browser render the real component.

How browser() Works

React exports browser from react-dom, while the component reads its value through use from react.

'use client'

import { Suspense, use } from 'react'
import { browser } from 'react-dom'

function DeviceTimeZone() {
  use(browser('The time zone comes from the browser device.'))

  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
  return <span>{timeZone}</span>
}

export function TimeZoneField() {
  return (
    <Suspense fallback={<span>Detecting time zone...</span>}>
      <DeviceTimeZone />
    </Suspense>
  )
}

Server behavior

During server rendering, use(browser()) stops rendering DeviceTimeZone. React finds the nearest Suspense boundary and leaves its fallback in the generated HTML.

This is a controlled bailout, not an error. React does not send browser-only content and does not pretend that it knows the device value.

Browser behavior

In the browser, passing the value from browser() to use returns undefined. Rendering continues immediately, and the component may read browser APIs.

The use call is what gives the value meaning. Calling browser() by itself does nothing, and React explicitly says not to throw the returned value.

A boundary is required

The call must sit under Suspense during server rendering. Without a boundary, React has no fallback to emit and the server render fails.

Keep the boundary close to the browser-dependent region. Wrapping an entire page means one small device-only widget can replace useful server-rendered content with a large loading state.

browser() Is Not the Same as use client

The names are easy to confuse in a Server Components framework.

use client defines a module boundary

The 'use client' directive tells a framework that a file exports Client Components. Those components may use state, effects, event handlers, and browser APIs after they reach the browser.

Client Components can still be prerendered on the server. In Next.js, marking a file with 'use client' does not automatically prevent its initial HTML from being generated during a server render or build.

browser() controls server rendering

use(browser()) tells React to stop server-rendering the current component and use Suspense fallback HTML instead. React requires the call to happen in a Client Component.

The two controls answer separate questions:

ControlQuestion it answers
'use client'Does this component belong to the client component graph and use client capabilities?
use(browser())Should this Client Component skip server rendering and finish in the browser?

Keep the client boundary narrow

Do not mark a large page as a Client Component only because one child uses browser(). Put the directive and the browser-only call in a focused child.

// app/settings/page.tsx, a Server Component
import { SavedDraftPanel } from './saved-draft-panel'

export default async function SettingsPage() {
  const account = await loadAccount()

  return (
    <main>
      <h1>Settings for {account.name}</h1>
      <SavedDraftPanel />
    </main>
  )
}

The page heading and account data remain server-rendered. Only the saved draft island waits for the browser.

Replace the Mounted-State Pattern

Many applications delay browser-dependent UI with a state flag.

function OldPattern() {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) return <DraftSkeleton />
  return <DraftEditor initialValue={localStorage.getItem('draft') ?? ''} />
}

What the effect is doing

The initial server render returns the skeleton. The first browser render must also return the skeleton so hydration matches. After the effect runs, state changes and React renders the editor.

The technique works, but the state is not application state. It encodes a rendering phase. Every component reimplements the same convention, and tests need to wait for an extra effect-driven render.

The React 19.3 version

'use client'

import { Suspense, use, useState } from 'react'
import { browser } from 'react-dom'

function SavedDraft() {
  use(browser('The saved draft is stored in localStorage.'))

  const [draft, setDraft] = useState(
    () => localStorage.getItem('draft') ?? ''
  )

  function updateDraft(value: string) {
    setDraft(value)
    localStorage.setItem('draft', value)
  }

  return (
    <textarea
      aria-label="Saved draft"
      value={draft}
      onChange={(event) => updateDraft(event.target.value)}
    />
  )
}

export function SavedDraftPanel() {
  return (
    <Suspense fallback={<DraftSkeleton />}>
      <SavedDraft />
    </Suspense>
  )
}

The fallback becomes part of the rendering contract. There is no temporary boolean and no effect whose only purpose is to detect hydration.

Do not convert every effect automatically

Effects remain correct for synchronizing with external systems after render. A chat connection, analytics subscription, DOM event listener, or media query observer may belong in an effect even when the component can render useful server HTML.

Use browser() when the component cannot produce its initial UI on the server. Do not use it merely because the component contains an effect.

Render a Server Value When You Have One

Skipping server rendering discards useful work. If the server has an honest default, render it.

Conditional use is supported

React's use API may be called conditionally, unlike ordinary Hooks. The React docs show a time-zone helper that returns a provided default and calls use(browser()) only when no default exists.

'use client'

import { use } from 'react'
import { browser } from 'react-dom'

function useTimeZone(defaultTimeZone?: string) {
  if (defaultTimeZone !== undefined) {
    return defaultTimeZone
  }

  use(browser('No server time zone was provided.'))
  return Intl.DateTimeFormat().resolvedOptions().timeZone
}

Prefer request-derived values for important UI

A server may infer locale or time zone from a user profile, cookie, or explicit setting. That value can produce stable HTML and remain accessible before hydration.

Device detection is useful for convenience, but it should not silently replace an account setting that affects deadlines, billing, or compliance. The data model decides which source is authoritative.

Decide whether the value should switch

If the server renders Europe/London and the device reports Asia/Kolkata, should the label change during hydration? Sometimes the answer is no. A conditional early return in the example above keeps the server value in both environments.

If the product needs to show both values, model them separately. Hidden hydration behavior is a poor place to define business semantics.

Browser-Side Data Caches

The API is also useful when a data library can read from a browser cache but the server did not receive initial data.

Render initial data when available

React's documentation shows a conditional query wrapper. It server-renders when initialData exists and bails to the browser when it does not.

function useBrowserQuery<T>(
  queryKey: string,
  options: { initialData?: T }
) {
  if (options.initialData === undefined) {
    use(browser(`No initial data for ${queryKey}`))
  }

  return useQuery({
    queryKey: [queryKey],
    initialData: options.initialData,
  })
}

In production code, avoid placing sensitive query identifiers in a reason that may reach logging. Use a stable category such as product-query-without-initial-data.

Browser-only fetching changes loading behavior

When no initial data exists, the server sends fallback HTML. After hydration, the query library may return cached data immediately or begin a request.

This can be appropriate for personalized secondary panels. It is a poor default for the main article, product details, pricing, or other content that should be visible to users and crawlers in the initial response.

Keep cache semantics explicit

A browser cache can contain stale or user-specific data. The component should still define freshness, error, and empty states. browser() chooses the rendering environment; it does not make the query correct.

Pass server-fetched data for critical content. Use the browser-only path for data whose absence from the server response is intentional.

Layout and DOM-Dependent Components

Tooltips, canvases, editors, charts, and maps often need browser primitives.

Separate the shell from the measurement

A chart card can render a heading, description, legend, and accessible table on the server. Only the canvas renderer may need a measured width.

export function SalesCard({ rows }: { rows: SalesRow[] }) {
  return (
    <section aria-labelledby="sales-title">
      <h2 id="sales-title">Sales by week</h2>
      <SalesTable rows={rows} />
      <Suspense fallback={<ChartPlaceholder />}>
        <MeasuredSalesChart rows={rows} />
      </Suspense>
    </section>
  )
}

MeasuredSalesChart can call use(browser()), while the semantic content remains in the response.

useLayoutEffect has several remedies

React's useLayoutEffect documentation lists browser-only rendering as one option for components that need layout information. Other options include switching to useEffect, rendering after hydration, or using useSyncExternalStore when synchronizing with an external store.

Choose based on behavior. A tooltip that appears only after a click does not need to exist in the initial HTML. A navigation menu does.

Third-party modules may fail before rendering

use(browser()) runs during component rendering. It cannot protect against a module that reads window at top-level import time on the server.

For those packages, use the framework's client-only dynamic import or isolate the import behind a browser entry point. The failure happens before React reaches the component body.

browser() vs next/dynamic

Next.js applications now have two clear browser-only mechanisms, plus the older mounted-state technique.

Use browser() for rendering semantics

Choose use(browser()) when the component module can load safely, the fallback belongs in Suspense, and you want the same React primitive across server-rendering environments.

The component remains in the client bundle. React simply avoids rendering its content on the server.

Use next/dynamic for lazy module loading

Next.js documents next/dynamic with { ssr: false } for a Client Component that should load only in the browser. This is especially useful for a heavy editor, map SDK, or package that touches browser globals when imported.

'use client'

import dynamic from 'next/dynamic'

const MapEditor = dynamic(() => import('./map-editor'), {
  ssr: false,
  loading: () => <MapPlaceholder />,
})

The option must live in a Client Component. Next.js does not allow ssr: false in a Server Component.

Compare the trade-offs

PatternServer HTMLCode splittingFallback ownerBest fit
use(browser())Nearest Suspense fallbackNo additional split by itselfReact SuspenseBrowser-only render with a safe module
next/dynamic({ ssr: false })Dynamic loading fallbackYesNext dynamic loaderBrowser-dependent or heavy module
Mounted state in useEffectInitial branch outputNoComponent stateLegacy code or behavior tied to post-hydration effect

The techniques can coexist, but layering all three around one component usually signals that the boundary needs simplification.

Design Better Suspense Fallbacks

The fallback is the server-rendered experience, not decorative loading chrome.

Preserve layout

Give the fallback dimensions close to the final component so hydration does not move surrounding content. A canvas or editor placeholder should reserve height, while a short text label can use a stable inline width.

Avoid a full-page spinner for a small browser-only control. It throws away the speed and accessibility benefits of server rendering.

Preserve meaning

If the final widget is optional, the fallback can explain that it appears after the page becomes interactive. If the value affects a form, label the pending field so assistive technology does not encounter an unexplained blank region.

Use aria-busy on an appropriate container when the state will update. Do not announce every small island as a live-region event after hydration.

Expect no-JavaScript behavior

Browser-only content never replaces its fallback when JavaScript is unavailable or blocked. Decide whether that is acceptable.

Critical navigation, purchase information, legal text, and primary article content should not depend on browser(). A saved local draft, interactive chart, or device capability control often can.

Observe Browser Bailouts

An explicit API makes browser-only rendering measurable.

Provide a reason

browser accepts an optional string or function. React passes the value as the cause of the error reported to onBrowserBailout during server rendering.

use(browser(() => new Error('Local draft requires browser storage.')))

A function is useful when constructing the reason is expensive because React does not call it in the browser.

Use onBrowserBailout in a custom renderer

Applications that call renderToPipeableStream directly can record intentional bailouts separately from failures.

const { pipe } = renderToPipeableStream(<App />, {
  onShellReady() {
    pipe(response)
  },
  onBrowserBailout(error, info) {
    logger.info('browser-only-render', {
      cause: String(error.cause),
      componentStack: info.componentStack,
    })
  },
  onError(error) {
    logger.error('server-render-failed', { error })
  },
})

Frameworks may decide how much of this callback they expose. Check the framework release that adds React 19.3 support instead of assuming a low-level option is configurable.

Watch for expanding boundaries

Count bailouts by reason and route. A sudden increase may mean a shared component began calling browser() higher in the tree, removing more server-rendered content than intended.

Do not treat every bailout as an error. Alert on unknown reasons, missing boundaries, or large shifts in frequency.

Test Both Rendering Environments

A client-only unit test proves only half the contract.

Assert the server fallback

Render the tree through a supported server renderer and confirm that the fallback appears while the browser-only content does not. Include a test without Suspense and verify that it fails in the expected way.

If the framework owns the renderer, use an integration test against a built route. Static snapshots of the component function will not model Suspense behavior accurately.

Assert hydration behavior

Load the server HTML in a real browser, seed localStorage or the relevant device API, hydrate, and assert that the component replaces the fallback without hydration warnings.

For time zones and locales, run the test in at least two browser contexts. A test that happens to use the same zone as the build machine misses the original class of bug.

Test accessibility before and after

Run automated checks on the server HTML and hydrated page. Verify focus order when a browser-only control appears. A fallback button that disappears and is replaced by another focused element can strand keyboard users.

Prefer stable surrounding structure and avoid auto-focusing the newly rendered component unless the user's action requested it.

Migration Strategy

Do not search for every useEffect(() => setMounted(true)) and replace it mechanically.

Inventory the reason for each workaround

Classify candidates by dependency: storage, time zone, layout, URL, third-party module import, browser cache, media device, or historical habit. Some components can be server-rendered after moving data loading up the tree.

Remove workarounds that no longer protect anything. Keep effects that perform real synchronization.

Start with leaf components

Convert small widgets under existing layout boundaries. Add Suspense with a purposeful fallback and tests for server HTML plus hydration.

Avoid starting with a root layout or provider. A browser bailout high in the tree can turn an entire page into fallback HTML.

Compare the production output

Measure HTML completeness, JavaScript size, layout shift, time to visible content, and hydration warnings before and after. browser() can simplify code while worsening the initial experience if used too broadly.

The best migration sometimes removes the browser-only condition entirely by passing a server value. Code reduction is useful, but preserved server content matters more.

When browser() Is the Wrong Choice

The API is attractive because it turns several lines of workaround code into one call. That does not mean the component should stop rendering on the server.

Do not hide deterministic content

If a value comes from props, route data, a cookie available to the server, or a database query, render it normally. Moving deterministic content behind a browser boundary increases the amount of empty or placeholder HTML and delays useful information until hydration.

This matters for product names, prices, availability, article text, account status, and form instructions. Search crawlers and link unfurlers may not execute the same client path as a browser. More importantly, a person on a slow device sees the fallback for longer even though the server already knew the answer.

Do not use it as an error suppressor

A hydration warning may reveal an unstable render, invalid nesting, a CSS-in-JS configuration error, or data that changed between response generation and hydration. Wrapping the component in use(browser()) can remove the warning by removing the server output, but it does not explain the underlying difference.

Identify the mismatched value first. If both environments should agree, fix the data flow. A deliberate browser-only boundary is appropriate only when agreement is impossible or the server output would be misleading.

Do not move authorization into the browser

A browser-only component is not a security boundary. Its JavaScript and network requests are visible to the user, and skipping server rendering does not protect private data.

Authenticate and authorize requests on the server. The client may decide how to present an allowed action, but the API must enforce whether the action is permitted. Never use browser() to conceal an admin control while leaving its endpoint unprotected.

Do not confuse rendering with capability detection

A component may render a stable shell on the server and check a capability after hydration. For example, a camera upload page can show instructions and an ordinary file picker in the initial HTML, then add direct camera capture when navigator.mediaDevices is available.

In that case, an effect or external-store subscription may be a better fit because the base interface remains useful. Skip the whole component only when its purpose depends on the browser capability.

Do not expand the boundary to simplify imports

If one dependency fails during server import, isolate that dependency. Moving a parent layout behind browser() may appear to solve the problem while causing unrelated headings, forms, and data to disappear from the server response.

Use a client-only dynamic import for the unsafe module, wrap the smallest component that owns it, or replace the package with one that supports server environments. The boundary should describe the UI requirement, not the location where an import error was easiest to silence.

Production Checklist

Before shipping a component with use(browser()):

  1. Confirm the component is a Client Component.
  2. Place it under the smallest useful Suspense boundary.
  3. Provide a fallback with stable dimensions and meaningful content.
  4. Verify the module itself does not access browser globals during server import.
  5. Check whether a real server value could avoid the bailout.
  6. Keep primary content and critical navigation server-rendered.
  7. Add a reason that contains no secrets or user data.
  8. Test generated server HTML, browser hydration, and the no-JavaScript state.
  9. Check focus order and layout shift when the component appears.
  10. Use next/dynamic instead when lazy import behavior is part of the requirement.
  11. Track bailouts where the renderer or framework exposes them.
  12. Revisit the boundary if a leaf requirement starts removing a large region of HTML.

browser() removes the fake state often used to model whether hydration has happened. It also makes the cost visible: the server emits a fallback and the browser owns the real render. Use that trade deliberately, keep the boundary small, and let the rest of the page benefit from server rendering.

For broader framework choices, see React vs Next.js. React, TypeScript, Vite, and Vitest Setup covers a client-rendered baseline, while Next.js 16.2 covers current server-rendered application patterns.

Official Sources


FAQs

What does browser() do in React 19.3?

browser returns an opaque value that a Client Component passes to use. During server rendering, React stops rendering that component and leaves the nearest Suspense fallback in the HTML. In the browser, the call does not suspend.

Does use(browser()) replace the use client directive?

No. In frameworks with Server Components, browser must be called from a Client Component. The use client directive defines the component boundary, while use(browser()) controls whether that component renders on the server.

Does browser() prevent the component's JavaScript from reaching the client?

No. The component is still client code and must be downloaded. browser changes server-rendering behavior; it is not a code-splitting API.

Does use(browser()) require Suspense?

Yes during server rendering. React requires a surrounding Suspense boundary so it has fallback HTML to leave in place. Without one, the server render fails.

When is next/dynamic with ssr false still useful?

Use next/dynamic when a Next.js application needs both client-only rendering and lazy module loading, especially for a browser-dependent third-party package. browser is useful when the component is already loaded and Suspense should own the fallback.

Should localStorage always use browser()?

No. If the server can render a meaningful default, render it and synchronize after hydration. Use browser when the browser value defines the initial UI and a server guess would be misleading or cause a mismatch.

How can teams observe browser-only rendering?

When using React's server renderer directly, pass onBrowserBailout and provide a reason to browser. The callback receives an error with the reason as its cause plus a component stack.

🚀

Work with us

Let's build something together

We build fast, modern websites and applications using Next.js, React, WordPress, Rust, and more. If you have a project in mind or just want to talk through an idea, we'd love to hear from you.

Related Articles


Live Chat