React Use Mobile Hook: SSR-sicher, ohne Hydration-Fehler

Früher oder später bekommt jede Next.js-App, die auf Smartphones laufen muss, einen React Use Mobile Hook: ein useIsMobile(), das unter 768px true liefert, kopiert aus einem Starter oder aus shadcn/ui. Im Browser funktioniert er, dann überrascht er dich auf drei Arten. Auf dem Server liefert er false, eine unvorsichtige Version wirft einen Hydration-Fehler, und ein Smartphone kann kurz die Desktop-Version von allem zeigen, was der Hook steuert. Dieses Tutorial erklärt, woher jedes dieser Probleme kommt, welche Aufgaben zu CSS-Breakpoints gehören und wie du einen SSR-sicheren Media-Query-Hook auf useSyncExternalStore schreibst, der sauber hydratisiert.
Warum ist useIsMobile auf dem Server false?
Weil der Server keinen Bildschirm hat. React rendert das HTML, bevor irgendein Browser eine Breite gemeldet hat. Ein Hook, der matchMedia liest, hat also nichts zu lesen und muss eine feste Annahme zurückgeben. Im Next.js App Router ist die Lücke noch größer: Eine Seite ohne Request-Time-APIs wird einmal beim Build vorgerendert, ganz ohne Request und ohne Besucher.
Die Annahme selbst schadet nicht. Das Problem ist die Hydration: Der erste Render im Client muss dasselbe Markup erzeugen wie der Server, also wiederholt der Browser die Annahme, und der Wert darf sich erst nach der Hydration ändern. Auf einem Smartphone bleibt die Antwort des Servers vom ersten Paint an stehen, bis das JavaScript geladen, geparst und hydratisiert ist, und auf einer langsamen Verbindung dauert das. Alles Sichtbare, das vom Hook abhängt, ist in diesem ganzen Zeitfenster falsch.
Die nützliche Frage lautet also: Welche Aufgaben vertragen in diesem Zeitfenster eine Annahme?
Warum verursacht ein useIsMobile Hook einen Hydration Mismatch?
Ein useIsMobile Hydration Mismatch entsteht nur, wenn der Hook window während des Renderns liest: Dann widerspricht der erste Render im Browser dem HTML des Servers. Wer nach einem React Is Mobile Hook sucht, findet drei Varianten, und nur die erste tut das. Wir haben jede mit renderToString aus React 19.3 auf dem Server gerendert, sie mit hydrateRoot in Chromium bei 390px über Playwright hydratisiert und 300ms später eine zweite Kopie gemountet, so wie ein Dialog, ein Lazy Panel oder eine clientseitige Route nach der Hydration mountet:
Die erste Variante ist die naheliegende:
Ändert der Hook Text, meldet React 19 Hydration failed because the server rendered text didn't match the client und baut diesen Baum im Client neu auf. Damit ist die Arbeit des SSR verloren. Die Next.js-Seite zum Hydration-Fehler (opens in a new tab) nennt genau diesen Check als häufige Ursache.
Wählt der Hook nur einen className, schlägt nichts fehl, und das ist schlimmer. In der Entwicklung loggt React 19 A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. Ein Production-Build loggt gar nichts. In unserem Test behielt das Smartphone in beiden Builds nach der Hydration class="desktop", und nichts korrigiert das, solange der Wert gleich bleibt.
Die zweite Variante ist der shadcn use-mobile Hook, der mit der shadcn/ui Sidebar kommt. Stand Oktober 2026 hält sein Quellcode (opens in a new tab) undefined im State, misst in einem Effect und gibt !!isMobile zurück:
Er erzeugt nie einen Mismatch, weil jeder erste Render false liefert. Der Haken steckt im Wort „jeder“: Auch eine Komponente, die lange nach der Hydration mountet, rendert einmal als Desktop und korrigiert sich dann im Effect. Ein autoFocus={!isMobile} auf einem Feld in dieser Komponente ist beim ersten Commit true, und React fokussiert das Feld während dieses Commits. Ein Smartphone kann also trotzdem die Tastatur bekommen, die du vermeiden wolltest.
Die shadcn Sidebar (opens in a new tab) kommt mit dem falschen Start aus gutem Grund durch. Das Desktop-Markup, das sie auf dem Server rendert, trägt hidden md:block, also sieht ein Smartphone es nie, und auf dem Smartphone tauscht die Korrektur ein Sheet ein, das geschlossen bleibt, bis jemand es öffnet. Dieses Muster solltest du kopieren, und der nächste Abschnitt erklärt es im Detail.
Wann solltest du CSS-Breakpoints statt eines Hooks nutzen?
Für alles, was vor der ersten Interaktion sichtbar ist. CSS Media Queries greifen beim ersten Paint, bevor irgendein JavaScript läuft, also stimmen sie schon im HTML des Servers. Render beide Versionen und lass einen Breakpoint eine davon ausblenden:
Beide Bäume stehen im DOM. Für eine Navigation oder eine Seite mit 20 Zeilen kostet das wenig. Hängt der Wechsel vom Platz ab, den eine Komponente bekommt, und nicht vom Fenster, erledigen das die Varianten @container und @md: von Tailwind v4 im CSS, ganz ohne Hook.
Den Hook behältst du für Entscheidungen, die erst beginnen, wenn jemand etwas tut, oder die beim ersten Paint nie sichtbar sind:
- welches Overlay sich öffnet: ein Bottom Sheet unter 768px, darüber ein Popover oder ein Dialog
- von welcher Kante ein Panel hereinfährt
- ob ein Feld den Fokus bekommt, wenn sich ein Panel öffnet, denn auf dem Smartphone öffnet der Fokus die Tastatur über dem Inhalt
- wohin ein Klick führt: auf dem Smartphone zu einer eigenen Seite, auf dem Desktop zu einem Seitenpanel neben der Liste
Ein geschlossenes Overlay rendert nur seinen Trigger, und der Trigger ist in beiden Modi meist derselbe Button. Der Hook kann das Overlay also frei umschalten. Das Tutorial zum responsiven shadcn Dialog baut diesen Wechsel für einen Dialog, und der Guide zu mobile-first React-Komponenten wendet dieselbe Regel auf eine ganze Komponentenbibliothek an.
Wie schreibst du einen SSR-sicheren useMediaQuery Hook?
Lies die Query über useSyncExternalStore, die React-API für Werte, die außerhalb von React leben, und gib ihr einen Server-Snapshot. Mehr braucht es nicht, um useMediaQuery SSR-sicher zu machen:
Das macht React damit:
- Server und Hydration. React ruft die dritte Funktion auf, also stimmen das HTML des Servers und der Hydration-Render überein. Lässt du sie weg, sagt die React-Doku (opens in a new tab) unmissverständlich, dass das Rendern der Komponente auf dem Server dann einen Fehler wirft.
- Direkt nach der Hydration. Sobald die Effects laufen, vergleicht React den Server-Snapshot mit der Antwort des Browsers und rendert synchron neu, wenn sie sich unterscheiden. Das ist das
false, danntrueaus der Tabelle oben. - Jeder spätere Mount. React ruft beim ersten Render die zweite Funktion auf. Ein Sheet, das sich auf dem Smartphone öffnet, rendert also vom ersten Frame an als Smartphone.
- Updates. Eine
MediaQueryListfeuertchangenur, wenn die Query umschlägt. Das Ziehen am Fensterrand kostet also keine Renders, bis es den Breakpoint überquert, anders als einresize-Listener. Die Listen werden pro Query gecacht, weilmatchesbei jedem Render gelesen wird.
Hat die shadcn Sidebar hooks/use-mobile.ts in dein Projekt gelegt, lass diese Datei auf den neuen Hook zeigen. Die Sidebar behält ihren Import, und ihr Desktop-Markup mit hidden md:block schaltet jetzt bei derselben Breite um wie der Hook:
Event-Handler brauchen den Hook gar nicht. Lies die Query im Moment des Klicks:
Welchen serverFallback solltest du übergeben?
Die Antwort, deren Irrtum im Hydration-Zeitfenster am wenigsten kostet. Für useIsMobile ist false meist richtig, weil die Entscheidungen, die er steuert, erst nach einem Tap beginnen, und bis dahin ist der echte Wert da. Für eine Query, die etwas Sichtbares ändert, wählst du die Antwort, die auf jedem Gerät funktioniert. Bei (hover: hover) ist das false: Der Server rendert die immer sichtbare Version eines Controls, und wer eine Maus nutzt, bekommt den Hover-Feinschliff einen Moment später.
Sollte die Query px oder rem nutzen?
Nimm die Einheit deiner CSS-Breakpoints. Tailwind v4 definiert --breakpoint-md: 48rem, und unser Build kompiliert md: zu @media (min-width: 48rem). Laut Media Queries Level 4 (opens in a new tab) beziehen sich relative Einheiten in Media Queries auf den Anfangswert. 48rem folgt also der Standard-Schriftgröße, die im Browser des Besuchers eingestellt ist, und ignoriert dein html { font-size }. Ein Hook in px bewegt sich nicht mit.
Wir haben das in Chromium über Playwright geprüft, mit einem 900px breiten Fenster und einer Standard-Schriftgröße von 20px. (min-width: 48rem) griff nicht mehr, also zeigte Tailwind das Smartphone-Layout, während (max-width: 767.98px) ebenfalls nicht griff, ein px-Hook meldete also weiter einen Desktop. (width < 48rem) griff, im Gleichschritt mit dem CSS. Die Range-Syntax macht außerdem den .98px-Trick überflüssig, den die Bootstrap-Doku (opens in a new tab) mit Viewports begründet, deren Breite kein ganzzahliger Pixelwert ist. Stand Oktober 2026 führen die Kompatibilitätsdaten von MDN (opens in a new tab) die Range-Syntax ab Chrome 104, Firefox 102 und Safari 16.4.
Breakpoints sind Tokens wie Farben und Radien, also pflege sie an einer Stelle: Der Guide zu Design Tokens in Tailwind v4 zeigt, wie du sie in Ebenen ordnest. Ein Vorbehalt zu unserem eigenen Code: Stand Oktober 2026 ist die MOBILE_QUERY in Wingo UI noch (max-width: 767.98px) und hat damit die oben beschriebene Lücke. Bei einer Standard-Schriftgröße von 20px schalten md:-Klassen bei 960px um, Drawer und Sheet dagegen, die useIsMobile() aufrufen, bei 768px. Ruf in deinen eigenen Komponenten useMediaQuery("(width < 48rem)") auf, wo Hook und Klassen übereinstimmen müssen.
Kann Next.js auf dem Server Mobilgeräte erkennen?
Es kann anhand der Request-Header raten, und zwar nur auf Routen, die bei jedem Request rendern. Wer auf dem Server mit React Mobile erkennen will, liest fast immer den User-Agent-Header. Next.js parst ihn mit userAgent() (opens in a new tab) aus next/server. Dessen device.type lautet "mobile", "tablet" oder einer von ein paar weiteren Werten, bei Desktop-Browsern undefined. Gib die Annahme als Server-Snapshot weiter, dann passt die Hydration trotzdem, weil die Prop mit der Seite mitkommt:
Kenn die Kosten, bevor du das einbaust:
- Dynamisches Rendering.
headers()(opens in a new tab) ist eine Request-Time-API. Die Seite rendert also bei jedem Request statt einmal beim Build, und jeder geteilte Cache muss nach User-Agent unterscheiden. Mit aktivierten Cache Components muss der Aufruf außerdem in einer<Suspense>-Boundary stehen, sonst schlägtnext buildfehl. - Der Header nennt ein Gerät. Er bleibt gleich für einen Desktop-Browser, der auf 600px verkleinert ist, eine Tablet-App im Split Screen und ein Smartphone im Querformat. Die Fensterbreite verrät er dir also nicht. Seit Safari 13 sendet Safari auf dem iPad standardmäßig denselben User-Agent wie Safari auf macOS (opens in a new tab), also gelten die meisten iPads als Desktop.
- Client Hints gibt es nur in Chromium.
Sec-CH-UA-Mobilekommt ohne Opt-in an, aber Stand Oktober 2026 führen die Kompatibilitätsdaten von MDN (opens in a new tab) den Header in Chrome und Edge und nicht in Firefox oder Safari.
Das lohnt sich auf App-Seiten, die ohnehin dynamisch sind, etwa ein Dashboard nach dem Login, und selten auf Marketing-Seiten, die du aus einem CDN ausliefern willst.
Wie nutzen Sheet und Drawer von Wingo UI den Hook?
Nur für Verhalten, und im geschlossenen Zustand rendern sie bei jeder Breite dasselbe Markup. Der kostenlose Hook useMediaQuery ist genauso gebaut, mit demselben Parameter serverFallback, dazu useIsMobile, useIsTouch für (pointer: coarse), useBreakpoint und matchesMedia. Zieh das Fenster über die 768px-Grenze oder öffne die Seite auf einem Smartphone:
$ npx wingo-ui@latest add use-media-queryDer Drawer ruft useIsMobile() in seiner Root-Komponente auf. Smartphones bekommen immer das ziehbare Sheet; ab 768px wählt die Prop desktop ein zentriertes Sheet, ein Seitenpanel oder einen Dialog. Sein Trigger ist in jedem Modus derselbe Radix-Dialog-Trigger, also ändert die Annahme des Servers nichts am Bildschirm. Der Open-State liegt in der Root-Komponente, also bleibt der Drawer offen, wenn jemand ein Tablet über 768px hinweg dreht.
$ npx wingo-ui@latest add drawerDas Sheet macht dasselbe für Seitenpanels. mobileSide="bottom" macht aus einem Filter-Panel von rechts auf dem Smartphone ein Bottom Sheet, und die Kante spielt erst eine Rolle, wenn das Sheet offen ist:
Was solltest du als Nächstes tun?
Kopier den Hook von oben nach hooks/use-media-query.ts oder installier unseren mit npx wingo-ui@latest add use-media-query. Dann durchsuch deine Codebase nach useIsMobile und sortier jeden Aufruf: Sichtbares Layout wandert in md:-Klassen, Verhalten bleibt beim Hook. Den anderen Hook, den die meisten Komponenten mit State teilen, erklärt der Guide zum Controllable State Hook in React. Mehr Artikel zum selben Stack findest du unter Next.js und Tailwind CSS.
Häufige Fragen
Warum liefert useIsMobile auf dem Server false?
Der Server rendert das HTML, bevor ein Browser eine Bildschirmbreite meldet. Der Hook hat also nichts zu messen und gibt eine feste Annahme zurück. Die Hydration muss diese Annahme wiederholen, der echte Wert kommt direkt danach.
Verursacht der useIsMobile Hook von shadcn einen Hydration Mismatch?
Nein. Der use-mobile Hook von shadcn hält undefined im State und misst den Bildschirm in einem Effect, also liefert sein erster Render auf dem Server und im Browser gleichermaßen false. Der Preis: Auch eine Komponente, die erst nach der Hydration mountet, rendert einmal als Desktop, bis der Effect das korrigiert.
Wie behebe ich einen Hydration Mismatch durch useMediaQuery?
Lies window nicht mehr während des Renderns, sondern lies die Query über useSyncExternalStore, mit einem getServerSnapshot, der auf dem Server und bei der Hydration denselben Wert liefert. React korrigiert den Wert dann direkt nach der Hydration, ohne Mismatch.
Kann Next.js auf dem Server ein Mobilgerät erkennen?
Es kann aus dem User-Agent-Header raten, mit userAgent() aus next/server, und dadurch rendert die Route bei jedem Request. Der Header nennt ein Gerät und sagt nichts über die Breite des Viewports. Nutz die Annahme also nur als Server-Snapshot und lass das Layout im CSS.
Warum widerspricht mein useIsMobile Hook dem md-Breakpoint von Tailwind?
Breakpoints in Tailwind v4 sind in rem angegeben (md ist 48rem), und Media Queries rechnen rem mit der Standard-Schriftgröße des Browsers um, während viele Hooks, auch der von shadcn, fest 768px eintragen. Erhöht jemand diese Schriftgröße, gehen beide auseinander. Schreib die Query des Hooks als (width < 48rem), dann schalten beide bei derselben Breite um.
Ist der useMediaQuery Hook von Wingo UI kostenlos?
Ja. useMediaQuery, useIsMobile und der Rest der Hook-Datei sind kostenlos, ebenso Sheet und Drawer, die ihn nutzen. Installier den Hook mit npx wingo-ui@latest add use-media-query.
- React
- Next.js
- Hooks
- SSR
- Tailwind CSS


