React Use Mobile Hook: SSR-Safe, No Hydration Mismatch
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:
The first shape is the obvious one:
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:
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:
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:
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, thentruein 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
MediaQueryListfireschangeonly when the query flips, so dragging the window edge costs no renders until it crosses the breakpoint, unlike aresizelistener. The lists are cached per query becausematchesis 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:
Event handlers do not need the hook at all. Read the query at the moment of the click:
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:
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, ornext buildfails. - 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-Mobilearrives 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:
$ npx wingo-ui@latest add use-media-queryThe 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.
$ npx wingo-ui@latest add drawerThe 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:
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.
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