# React Use Mobile Hook: SSR-Safe, No Hydration Mismatch

> Why a React use mobile hook is false on the server, when CSS breakpoints beat it, and an SSR-safe useSyncExternalStore version with no hydration mismatch.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-use-mobile-hook-ssr

## TL;DR

A React use mobile hook returns false on the server because the server has no screen: it renders the HTML before any browser reports a width, and hydration has to repeat that guess. Switch visible layout with CSS breakpoints, which apply at first paint, and keep the hook for behavior after an interaction, such as opening a bottom sheet instead of a dialog. Build the hook on useSyncExternalStore with a server snapshot, so hydration matches and every later mount reads the real value on its first render.

Sooner or later, a Next.js app that has to work on phones grows a React use mobile hook: a `useIsMobile()` that returns true below 768px, copied from a starter or from shadcn/ui. It works in the browser, then surprises you three ways: it returns false on the server, a careless version throws a hydration error, and a phone can briefly see the desktop version of whatever the hook controls. This tutorial explains where each one comes from, which jobs belong to CSS breakpoints, and how to write an SSR-safe media query hook on `useSyncExternalStore` that hydrates cleanly.

## Why is useIsMobile false on the server?

Because the server has no screen. React renders the HTML before any browser has reported a width, so a hook that reads `matchMedia` has nothing to read and must return a fixed guess. In the Next.js App Router the gap is wider still: a page that uses no request-time APIs is prerendered once at build time, with no request and no visitor at all.

The guess itself is harmless. The trouble is hydration: the first client render has to produce the same markup as the server, so the browser repeats the guess, and the value may only change after hydration. On a phone, the server's answer stays on screen from the first paint until the JavaScript has downloaded, parsed and hydrated, and on a slow connection that takes a while. Anything visible that depends on the hook is wrong for that whole window.

So the useful question is which jobs can live with a guess for that window.

## Why does a useIsMobile hook cause a hydration mismatch?

Only when it reads `window` during render: the browser's first render then disagrees with the server's HTML. Search for a React is mobile hook and you find three shapes, and only the first one does that. We server-rendered each one with `renderToString` from React 19.3, hydrated it with `hydrateRoot` in Chromium at 390px through Playwright, and mounted a second copy 300ms later, the way a dialog, a lazy panel or a client-side route mounts after hydration:

| Pattern | Hydration at 390px | First render of a later mount |
| --- | --- | --- |
| `typeof window` check in render | Text: an error, the tree is rebuilt on the client. A class: no error, the server's class stays | `true` |
| `useState` + `useEffect` | No error: `false`, then `true` after the effect | `false`, then `true` |
| `useSyncExternalStore` with a server snapshot | No error: `false`, then `true` right after hydration | `true` |

The first shape is the obvious one:

```tsx
// wrong: the server says false, a phone says true during hydration
const isMobile = typeof window !== "undefined" && matchMedia("(max-width: 767.98px)").matches;
```

When the hook changes text, React 19 reports "Hydration failed because the server rendered text didn't match the client" and rebuilds that tree on the client, which throws away the work SSR did. The [Next.js hydration error page](https://nextjs.org/docs/messages/react-hydration-error) lists this exact check as a common cause.

When the hook only picks a `className`, nothing fails, and that is worse. In development React 19 logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up." A production build logs nothing. In our test the phone kept `class="desktop"` after hydration in both builds, and nothing corrects it while the value stays the same.

The second shape is the shadcn use-mobile hook that ships with the shadcn/ui Sidebar. As of October 2026 [its source](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/hooks/use-mobile.ts) keeps `undefined` in state, measures in an effect and returns `!!isMobile`:

```ts
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

React.useEffect(() => {
  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
  // ...subscribes to mql, then:
  setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}, [])

return !!isMobile
```

It never mismatches, because every first render returns `false`. The catch is the word every: a component that mounts long after hydration also renders as a desktop once, then corrects itself in the effect. An `autoFocus={!isMobile}` on a field in that component is true on the first commit, and React focuses the field during that commit, so a phone can still get the keyboard you meant to avoid.

The [shadcn Sidebar](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/sidebar.tsx) gets away with the false start for a good reason. The desktop markup it renders on the server carries `hidden md:block`, so a phone never sees it, and on a phone the correction swaps in a Sheet that stays closed until someone opens it. That is the pattern to copy, and the next section spells it out.

## When should you use CSS breakpoints instead of a hook?

For everything visible before the first interaction. CSS media queries apply at first paint, before any JavaScript runs, so they are already right in the server's HTML. Render both versions and let a breakpoint hide one:

```tsx
// app/orders/page.tsx: a server component, no hook to correct
import { getOrders } from "@/lib/orders";
import { OrderCard } from "./order-card";
import { OrderRow } from "./order-row";

export default async function OrdersPage() {
  const orders = await getOrders();
  return (
    <>
      <ul className="flex flex-col gap-2 md:hidden">
        {orders.map((order) => (
          <OrderCard key={order.id} order={order} />
        ))}
      </ul>
      <table className="hidden w-full md:table">
        <tbody>
          {orders.map((order) => (
            <OrderRow key={order.id} order={order} />
          ))}
        </tbody>
      </table>
    </>
  );
}
```

Both trees are in the DOM, which costs little for a nav or a page of 20 rows. When the switch depends on the space a component gets rather than the window, Tailwind v4's `@container` and `@md:` variants do it in CSS with no hook at all.

Keep the hook for decisions that start after the person does something, or that never show on the first paint:

- which overlay opens: a bottom sheet below 768px, a popover or a dialog above it
- the edge a panel slides in from
- whether a field takes focus when a panel opens, since focusing it on a phone pops the keyboard over the content
- where a click goes: a full page on a phone, a side panel next to the list on desktop

A closed overlay renders only its trigger, and the trigger is usually the same button in both modes, so the hook can switch the overlay freely. The [shadcn responsive dialog](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile) tutorial builds that switch for a dialog, and the [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components) guide applies the same rule across a whole component library.

## How do you write an SSR-safe useMediaQuery hook?

Read the query through `useSyncExternalStore`, React's API for values that live outside React, and give it a server snapshot. That is the whole fix for useMediaQuery SSR problems:

```ts
// hooks/use-media-query.ts
import { useCallback, useSyncExternalStore } from "react";

// the complement of Tailwind's md: variant, in the same unit
export const MOBILE_QUERY = "(width < 48rem)";

const lists = new Map<string, MediaQueryList>();

function getList(query: string) {
  let list = lists.get(query);
  if (!list) {
    list = window.matchMedia(query);
    lists.set(query, list);
  }
  return list;
}

export function useMediaQuery(query: string, serverFallback = false) {
  // stable per query, or React unsubscribes and subscribes again on every render
  const subscribe = useCallback(
    (onChange: () => void) => {
      const list = getList(query);
      list.addEventListener("change", onChange);
      return () => list.removeEventListener("change", onChange);
    },
    [query],
  );
  return useSyncExternalStore(
    subscribe,
    () => getList(query).matches,
    // the server and the hydration render
    () => serverFallback,
  );
}

export function useIsMobile() {
  return useMediaQuery(MOBILE_QUERY);
}

// the same check in event handlers and effects; false on the server
export function matchesMedia(query: string) {
  return typeof window !== "undefined" && getList(query).matches;
}
```

Here is what React does with it:

- **Server and hydration.** React calls the third function, so the server HTML and the hydration render agree. Leave it out and the [React docs](https://react.dev/reference/react/useSyncExternalStore) are blunt: "rendering the component on the server will throw an error."
- **Right after hydration.** Once the effects run, React compares the server snapshot with the browser's answer and, when they differ, re-renders synchronously. That is the `false`, then `true` in the table above.
- **Every later mount.** React calls the second function during the first render, so a sheet that opens on a phone renders as a phone from its first frame.
- **Updates.** A `MediaQueryList` fires `change` only when the query flips, so dragging the window edge costs no renders until it crosses the breakpoint, unlike a `resize` listener. The lists are cached per query because `matches` is read on every render.

If the shadcn Sidebar added `hooks/use-mobile.ts` to your project, point that file at the new hook. The Sidebar keeps its import, and its `hidden md:block` desktop markup now flips at the same width as the hook:

```ts
// hooks/use-mobile.ts
import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query";

export const useIsMobile = () => useMediaQuery(MOBILE_QUERY);
```

Event handlers do not need the hook at all. Read the query at the moment of the click:

```tsx
// app/orders/use-open-order.ts
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { MOBILE_QUERY, matchesMedia } from "@/hooks/use-media-query";

export function useOpenOrder() {
  const router = useRouter();
  const [selected, setSelected] = useState<string | null>(null);

  function openOrder(id: string) {
    // phones get a full page, desktops a panel next to the list
    if (matchesMedia(MOBILE_QUERY)) router.push(`/orders/${id}`);
    else setSelected(id);
  }

  return { selected, openOrder };
}
```

### Which serverFallback should you pass?

The answer whose mistake costs least during the hydration window. For `useIsMobile`, `false` is usually right, because the decisions it drives start after a tap and by then the real value is in. For a query that changes something visible, pick the answer that works on every device. With `(hover: hover)` that is `false`: the server renders the always-visible version of a control, and a mouse user gets the hover polish a moment later.

### Should the query use px or rem?

Use the unit of your CSS breakpoints. Tailwind v4 defines `--breakpoint-md: 48rem`, and our build compiles `md:` to `@media (min-width: 48rem)`. [Media Queries Level 4](https://www.w3.org/TR/mediaqueries-4/#units) says relative units in media queries are "based on the initial value", so 48rem follows the default font size set in the visitor's browser and ignores your `html { font-size }`. A hook written in px does not move with it.

We checked this in Chromium through Playwright, with a 900px window and the default font size set to 20px. `(min-width: 48rem)` stopped matching, so Tailwind showed the phone layout, while `(max-width: 767.98px)` did not match either, so a px hook still reported a desktop. `(width < 48rem)` matched, in step with the CSS. Range syntax also retires the `.98px` trick that [Bootstrap's docs](https://getbootstrap.com/docs/5.3/layout/breakpoints/) explain with "viewports with fractional widths". As of October 2026, [MDN's compatibility data](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media) lists range syntax from Chrome 104, Firefox 102 and Safari 16.4.

Breakpoints are tokens like colors and radii, so keep them in one place: the [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens) guide shows how to layer them. A caveat about our own code: as of October 2026 the `MOBILE_QUERY` in Wingo UI is still `(max-width: 767.98px)`, so it has the gap described above. With a 20px default font size, `md:` classes switch at 960px while the Drawer and the Sheet, which call `useIsMobile()`, switch at 768px. In your own components, call `useMediaQuery("(width < 48rem)")` where the hook and your classes have to agree.

## Can Next.js detect mobile on the server?

It can guess from request headers, and only on routes rendered per request. Most ways to make React detect mobile or desktop on the server read the User-Agent header. Next.js parses it with [`userAgent()`](https://nextjs.org/docs/app/api-reference/functions/userAgent) from `next/server`, whose `device.type` is `"mobile"`, `"tablet"` and a few others, or `undefined` for desktop browsers. Pass the guess down as the server snapshot and hydration still matches, because the prop travels with the page:

```tsx
// app/orders/page.tsx
import { headers } from "next/headers";
import { userAgent } from "next/server";
import { OrdersScreen } from "./orders-screen";

export default async function OrdersPage() {
  const { device } = userAgent({ headers: await headers() });
  return <OrdersScreen phoneGuess={device.type === "mobile"} />;
}
```

```tsx
// app/orders/orders-screen.tsx
"use client";

import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query";

export function OrdersScreen({ phoneGuess }: { phoneGuess: boolean }) {
  // the server and the hydration render both use the guess, then the real value takes over
  const isMobile = useMediaQuery(MOBILE_QUERY, phoneGuess);
  return <p>{isMobile ? "Tap an order to open it" : "Select an order to see it on the right"}</p>;
}
```

Know the cost before you add it:

- **Dynamic rendering.** [`headers()`](https://nextjs.org/docs/app/api-reference/functions/headers) is a request-time API, so the page renders on every request instead of once at build time, and any shared cache has to vary by user agent. With Cache Components turned on, the read also has to sit inside a `<Suspense>` boundary, or `next build` fails.
- **It names a device.** The header stays the same for a desktop browser narrowed to 600px, a tablet app in split screen and a phone turned to landscape, so it cannot tell you the window width. Since Safari 13, [Safari on iPad sends the same user agent as Safari on macOS](https://webkit.org/blog/9674/new-webkit-features-in-safari-13/) by default, so most iPads read as desktops.
- **Client hints cover Chromium only.** `Sec-CH-UA-Mobile` arrives without opting in, but as of October 2026 [MDN's compatibility data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-CH-UA-Mobile) lists it in Chrome and Edge and not in Firefox or Safari.

It pays off on app pages that are dynamic anyway, such as a signed-in dashboard, and rarely on marketing pages you want served from a CDN.

## How do the Wingo UI Sheet and Drawer use the hook?

Only for behavior, and their closed state renders the same markup at every width. The free [useMediaQuery](https://wingo-ui.com/components/use-media-query) hook is built the same way, with the same `serverFallback` parameter, plus `useIsMobile`, `useIsTouch` for `(pointer: coarse)`, `useBreakpoint` and `matchesMedia`. Resize this window across 768px, or open the page on a phone:

> Live demo (useMediaQuery): Resize the window across 768px: the useIsMobile() card and the (width < 48rem) card flip together at the default font size, and the ruler moves to the widest Tailwind breakpoint that matches. Try it at [useMediaQuery](https://wingo-ui.com/components/use-media-query) and install it with `npx wingo-ui@latest add use-media-query`.

The [Drawer](https://wingo-ui.com/components/drawer) calls `useIsMobile()` in its root. Phones always get the draggable sheet; from 768px the `desktop` prop picks a centered sheet, a side panel or a dialog. Its trigger is the same Radix Dialog trigger in every mode, so the server's guess changes nothing on screen, and the open state lives in the root, so a tablet rotated across 768px keeps the drawer open.

> Live demo (Drawer): Tap Filters: below 768px the drawer slides up as a sheet you can drag down to close; on a wider screen the same component opens a centered dialog. Try it at [Drawer](https://wingo-ui.com/components/drawer) and install it with `npx wingo-ui@latest add drawer`.

The [Sheet](https://wingo-ui.com/components/sheet) does the same for side panels. `mobileSide="bottom"` turns a filter panel from the right into a bottom sheet on phones, and the side only matters once the sheet is open:

```tsx
import { SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet } from "@/components/ui/sheet";

export function OrderFilters() {
  return (
    <Sheet
      side="right"
      mobileSide="bottom"
      title="Filters"
      description="Status, date and amount"
      trigger={
        <Button variant="soft" leftIcon={<SlidersHorizontal />}>
          Filters
        </Button>
      }
    >
      <p className="text-sm text-muted-foreground">Pick the orders you want to see.</p>
    </Sheet>
  );
}
```

## What should you do next?

Paste the hook above into `hooks/use-media-query.ts`, or install ours with `npx wingo-ui@latest add use-media-query`. Then search your codebase for `useIsMobile` and sort every call: visible layout moves to `md:` classes, behavior stays on the hook. More posts on the same stack live in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind).

## Components in this post

- [useMediaQuery](https://wingo-ui.com/components/use-media-query) (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: `npx wingo-ui@latest add use-media-query`
- [Sheet](https://wingo-ui.com/components/sheet) (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: `npx wingo-ui@latest add sheet`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`

## FAQ

### Why does useIsMobile return false on the server?

The server renders the HTML before any browser reports a screen width, so the hook has nothing to measure and returns a fixed guess. Hydration has to repeat that guess, and the real value arrives right after it.

### Does the shadcn useIsMobile hook cause a hydration mismatch?

No. The shadcn use-mobile hook keeps undefined in state and measures the screen in an effect, so its first render returns false on the server and in the browser alike. The cost is that a component mounted after hydration also renders as a desktop once, until the effect corrects it.

### How do I fix a hydration mismatch caused by useMediaQuery?

Stop reading window during render and read the query through useSyncExternalStore, with a getServerSnapshot that returns the same value on the server and during hydration. React then corrects the value right after hydration, with no mismatch.

### Can Next.js detect a mobile device on the server?

It can guess from the User-Agent header with userAgent() from next/server, which makes the route render per request. The header names a device and says nothing about the viewport width, so use the guess as the server snapshot only and keep layout in CSS.

### Why does my useIsMobile hook disagree with Tailwind's md breakpoint?

Tailwind v4 breakpoints are in rem (md is 48rem), and media queries resolve rem from the browser's default font size, while many hooks, shadcn's included, hard-code 768px. When a visitor raises that font size the two disagree; write the hook's query as (width < 48rem) and both flip at the same width.

### Is the Wingo UI useMediaQuery hook free?

Yes. useMediaQuery, useIsMobile and the rest of the hook file are free, and so are the Sheet and the Drawer that use it. Install the hook with npx wingo-ui@latest add use-media-query.

---

Source: https://wingo-ui.com/blog/react-use-mobile-hook-ssr
