iPhone 15
Macintosh; Intel Mac OS X · screen: 393px'mobile''ios'With Safari's "Request Desktop Website" even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.
Know whether your user is on a phone, tablet, or desktop, and which OS they run. It catches the iPad that writes "Mac" into its own user agent, and it does not break in Next.js or anywhere else that builds HTML on the server first. No dependencies.
At a glance
These are the devices where trusting the User-Agent string gets you the wrong answer.
Macintosh; Intel Mac OS X · screen: 393px'mobile''ios'With Safari's "Request Desktop Website" even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.
Linux; Android 14; K'mobile''android'Chrome stripped the model name out of the UA, so every Android just reports "K". Client Hints, which browsers send separately, still answer it.
Macintosh; Intel Mac OS X 10_15_7'tablet''ios'Has claimed to be a Mac since iPadOS 13. Five touch points give it away as a tablet anyway.
Macintosh; Intel Mac OS X 10_15_7 · maxTouchPoints: 0'desktop''macos'A real Mac sending the exact same UA as the iPad above. One fact separates them: maxTouchPoints is 0.
Windows NT 10.0 · maxTouchPoints: 10'desktop''windows'A touchscreen does not change the answer. maxTouchPoints is only consulted to catch devices claiming to be Macs, so this stays desktop.
Linux; Android 11; SHIELD Android TV'desktop''android'An Android UA without the Mobile token would normally land in the tablet bucket, but TV markers are checked first. Desktop is the closest fit for a screen you drive with a remote from across the room.
Live demo
This page is HTML that Next.js built on the server. The left panel is exactly what the server sent. The right panel is what the hook knows right now.
Always desktop / unknown, whatever device you arrive on. Server and browser start from the same value, so they cannot disagree.
The moment the browser takes over the page, one render fills in the real values. Touch and orientation keep following after that.
Open this page on a phone, or reload with DevTools device emulation. The left panel stays desktop while the right one tells the truth, and the console logs zero hydration errors.
Usage
From a single boolean to running without React at all. You ship only what you import.
One call gives you the device class, the OS, convenience booleans, and the values that change live. Server and browser always agree on the first paint, so you never write typeof window guards. Check isHydrated only when you want to hide the flash as the real values land.
import { useDevice } from 'react-device-check';
export default function Page() {
const { type, os, isMobile, isHydrated } = useDevice();
// Server render & hydration first paint: type = 'desktop', isHydrated = false.
// One render later the real device shows up. No mismatch, ever.
if (!isHydrated) return <Skeleton />;
if (isMobile && os === 'ios') return <AppStoreBanner />;
return <p>You are on a {type} running {os}.</p>;
}Whatever you do not import is dropped at build time. Take only useIsMobile and useOS and the live-watching code disappears entirely, leaving ~1.1 kB. Perfect for OS-specific store buttons.
import { useIsMobile, useOS } from 'react-device-check';
export default function DownloadButton() {
const isMobile = useIsMobile(); // boolean only, ~1.1 kB total
const os = useOS(); // 'ios' | 'android' | ...
if (isMobile && os === 'ios') return <AppStoreButton />;
if (isMobile && os === 'android') return <PlayStoreButton />;
return <DesktopDownloadButton />;
}The function that does the actual detecting is exported on its own. No React, no browser globals. Hand it a UA string and you get the same answer in middleware, on a server, or in a test.
import { detectDevice } from 'react-device-check';
export function middleware(request: Request) {
// No React, no globals. Inject the signals you have
const { type } = detectDevice({
ua: request.headers.get('user-agent') ?? '',
});
if (type === 'mobile') {
// e.g. rewrite to the lightweight variant
}
}Why
An iPad writing "Mac" into its user agent is still caught as a tablet, because maxTouchPoints is read alongside it. Android tablets follow the rule Google documents, and Samsung DeX and in-app webviews are handled too.
On Chromium it reads Client Hints instead of the UA. Stripping the model name does not affect them. Only browsers without Client Hints fall back to parsing the UA string.
Server and browser use the same value on the first paint, so the two cannot disagree. That is why React 18/19 never log a hydration error here. The real values arrive one render later.
No dependencies at all. Importing only useIsMobile ships ~1.1 kB, and the live-watching code never enters the bundle. CI checks these numbers.
Device class and OS stay pinned until the page reloads. Touch and orientation update live, so unfolding a foldable or attaching a keyboard to an iPad still gives the right answer.
Beyond 98 unit tests, iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, and desktop Chrome and Safari are driven in real browsers to confirm the verdicts and zero errors.
Comparison
react-device-detect computes its values the moment you import it and freezes them. That breaks under server rendering. It calls iPads desktops, never updates, and because unused parts cannot be dropped it always ships ~13 kB. It has been unmaintained since 2023, and its parser dependency moved to AGPL. react-device-check is a maintained MIT replacement designed around how platforms behave today.
API
| API | Description |
|---|---|
| useDevice() | Device class, OS, convenience booleans, and the live touch and orientation values in one call. |
| useDeviceType() | 'mobile' | 'tablet' | 'desktop'. Static per session, listener-free. |
| useIsMobile() · useIsTablet() · useIsDesktop() | When one boolean is all you need. Importing only these drops the live-watching code from the bundle. |
| useOS() | 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'. |
| useDevicePixelRatio() | The physical pixels per CSS pixel, for @2x assets and canvas scaling. Reactive, and 1 until hydration. |
| detectDevice(input?, options?) | The detection function without React. Pass the values in yourself, which suits servers and tests. |
Full API reference and known limitations live in the GitHub README.