WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Next.js and Tailwind CSS
  4. React Use Mobile Hook: SSR-Safe, No Hydration Mismatch
  1. Blog
  2. Next.js and Tailwind CSS
  3. React Use Mobile Hook: SSR-Safe, No Hydration Mismatch
Next.js and Tailwind CSS
Next.js and Tailwind CSS

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

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 9 min read

On this page

0%
  1. Why is useIsMobile false on the server?
  2. Why does a useIsMobile hook cause a hydration mismatch?
  3. When should you use CSS breakpoints instead of a hook?
  4. How do you write an SSR-safe useMediaQuery hook?
    1. Which serverFallback should you pass?
    2. Should the query use px or rem?
  5. Can Next.js detect mobile on the server?
  6. How do the Wingo UI Sheet and Drawer use the hook?
  7. What should you do next?
  8. FAQ
    1. Why does useIsMobile return false on the server?
    2. Does the shadcn useIsMobile hook cause a hydration mismatch?
    3. How do I fix a hydration mismatch caused by useMediaQuery?
    4. Can Next.js detect a mobile device on the server?
    5. Why does my useIsMobile hook disagree with Tailwind's md breakpoint?
    6. Is the Wingo UI useMediaQuery hook free?

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.

Published Oct 9, 2026

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:

PatternHydration at 390pxFirst render of a later mount
typeof window check in renderText: an error, the tree is rebuilt on the client. A class: no error, the server's class staystrue
useState + useEffectNo error: false, then true after the effectfalse, then true
useSyncExternalStore with a server snapshotNo error: false, then true right after hydrationtrue

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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 tutorial builds that switch for a dialog, and the 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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) explain with "viewports with fractional widths". As of October 2026, MDN's compatibility data (opens in a new tab) 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 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() (opens in a new tab) 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() (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 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:

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
$ npx wingo-ui@latest add use-media-query
FreeuseMediaQuery docs

The 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.

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
$ npx wingo-ui@latest add drawer
FreeDrawer docs

The 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.

Components in this post

  • useMediaQuery

    SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint.

    Free
  • Sheet

    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.

    Free
  • Drawer

    The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free

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.

  • React
  • Next.js
  • Hooks
  • SSR
  • Tailwind CSS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

Build a shadcn responsive dialog once: a centered card above 768px, a draggable bottom sheet below it, one copy of the content and no hydration flash.

SRSerban Rusu·Oct 9, 2026·10 min read
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

React Bottom Sheet for the Web: Snap Points, Drag, Keyboard

Build a React bottom sheet for the web on vaul: snap points, drag to dismiss, nested sheets, the on-screen keyboard and the home indicator, with working code.

SRSerban Rusu·Oct 9, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI