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

> Warum ein React Use Mobile Hook auf dem Server false liefert, wann CSS-Breakpoints besser passen und wie useSyncExternalStore den Hydration Mismatch verhindert.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 9. Okt. 2026
- Kategorie: [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind)
- Lesezeit: 9 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-use-mobile-hook-ssr
- Englische Fassung: https://wingo-ui.com/blog/react-use-mobile-hook-ssr

## Kurz gesagt

Ein React Use Mobile Hook liefert auf dem Server false, weil der Server keinen Bildschirm hat: Er rendert das HTML, bevor irgendein Browser eine Breite meldet, und die Hydration muss diese Annahme wiederholen. Sichtbares Layout schaltest du mit CSS-Breakpoints um, die schon beim ersten Paint greifen. Den Hook behältst du für Verhalten nach einer Interaktion, etwa ein Bottom Sheet statt eines Dialogs. Bau ihn auf useSyncExternalStore mit einem Server-Snapshot, dann passt die Hydration und jeder spätere Mount liest schon beim ersten Render den echten Wert.

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:

| Variante | Hydration bei 390px | Erster Render eines späteren Mounts |
| --- | --- | --- |
| `typeof window`-Check im Render | Text: ein Fehler, der Baum wird im Client neu aufgebaut. Eine Klasse: kein Fehler, die Klasse des Servers bleibt | `true` |
| `useState` + `useEffect` | Kein Fehler: `false`, nach dem Effect `true` | `false`, dann `true` |
| `useSyncExternalStore` mit Server-Snapshot | Kein Fehler: `false`, direkt nach der Hydration `true` | `true` |

Die erste Variante ist die naheliegende:

```tsx
// falsch: Der Server sagt false, ein Smartphone sagt während der Hydration true
const isMobile = typeof window !== "undefined" && matchMedia("(max-width: 767.98px)").matches;
```

Ä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](https://nextjs.org/docs/messages/react-hydration-error) 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](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/hooks/use-mobile.ts) `undefined` im State, misst in einem Effect und gibt `!!isMobile` zurück:

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

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

return !!isMobile
```

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](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/sidebar.tsx) 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:

```tsx
// app/orders/page.tsx: eine Server Component, kein Hook zum Korrigieren
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>
    </>
  );
}
```

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](https://wingo-ui.com/de/blog/shadcn-responsive-dialog-on-mobile) baut diesen Wechsel für einen Dialog, und der Guide zu [mobile-first React-Komponenten](https://wingo-ui.com/de/blog/mobile-first-react-components) 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:

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

// das Gegenstück zur md:-Variante von Tailwind, in derselben Einheit
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) {
  // stabil pro Query, sonst meldet React das Abo bei jedem Render ab und neu an
  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,
    // der Server und der Hydration-Render
    () => serverFallback,
  );
}

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

// derselbe Check in Event-Handlern und Effects; auf dem Server false
export function matchesMedia(query: string) {
  return typeof window !== "undefined" && getList(query).matches;
}
```

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](https://react.dev/reference/react/useSyncExternalStore) 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`, dann `true` aus 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 `MediaQueryList` feuert `change` nur, wenn die Query umschlägt. Das Ziehen am Fensterrand kostet also keine Renders, bis es den Breakpoint überquert, anders als ein `resize`-Listener. Die Listen werden pro Query gecacht, weil `matches` bei 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:

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

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

Event-Handler brauchen den Hook gar nicht. Lies die Query im Moment des Klicks:

```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) {
    // Smartphones bekommen eine eigene Seite, Desktops ein Panel neben der Liste
    if (matchesMedia(MOBILE_QUERY)) router.push(`/orders/${id}`);
    else setSelected(id);
  }

  return { selected, openOrder };
}
```

### 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](https://www.w3.org/TR/mediaqueries-4/#units) 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](https://getbootstrap.com/docs/5.3/layout/breakpoints/) mit Viewports begründet, deren Breite kein ganzzahliger Pixelwert ist. Stand Oktober 2026 führen die [Kompatibilitätsdaten von MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media) 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](https://wingo-ui.com/de/blog/tailwind-v4-design-tokens) 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()`](https://nextjs.org/docs/app/api-reference/functions/userAgent) 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:

```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 }) {
  // Server und Hydration-Render nutzen beide die Annahme, dann übernimmt der echte Wert
  const isMobile = useMediaQuery(MOBILE_QUERY, phoneGuess);
  return <p>{isMobile ? "Tippe auf eine Bestellung, um sie zu öffnen" : "Wähle eine Bestellung, um sie rechts zu sehen"}</p>;
}
```

Kenn die Kosten, bevor du das einbaust:

- **Dynamisches Rendering.** [`headers()`](https://nextjs.org/docs/app/api-reference/functions/headers) 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ägt `next build` fehl.
- **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](https://webkit.org/blog/9674/new-webkit-features-in-safari-13/), also gelten die meisten iPads als Desktop.
- **Client Hints gibt es nur in Chromium.** `Sec-CH-UA-Mobile` kommt ohne Opt-in an, aber Stand Oktober 2026 führen die [Kompatibilitätsdaten von MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-CH-UA-Mobile) 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](https://wingo-ui.com/components/use-media-query) 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:

> Live-Demo (useMediaQuery): Zieh das Fenster über die 768px-Grenze: Die Karte useIsMobile() und die Karte (width < 48rem) schalten bei der Standard-Schriftgröße gemeinsam um, und das Lineal springt zum breitesten Tailwind-Breakpoint, der greift. Probier sie unter [useMediaQuery](https://wingo-ui.com/components/use-media-query) aus und installier sie mit `npx wingo-ui@latest add use-media-query`.

Der [Drawer](https://wingo-ui.com/components/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.

> Live-Demo (Drawer): Tippe auf „Filters“: Unter 768px fährt der Drawer als Sheet hoch, das du nach unten ziehen kannst, um es zu schließen; auf einem breiteren Bildschirm öffnet dieselbe Komponente einen zentrierten Dialog. Probier sie unter [Drawer](https://wingo-ui.com/components/drawer) aus und installier sie mit `npx wingo-ui@latest add drawer`.

Das [Sheet](https://wingo-ui.com/components/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:

```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="Filter"
      description="Status, Datum und Betrag"
      trigger={
        <Button variant="soft" leftIcon={<SlidersHorizontal />}>
          Filter
        </Button>
      }
    >
      <p className="text-sm text-muted-foreground">Wähle die Bestellungen, die du sehen willst.</p>
    </Sheet>
  );
}
```

## 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](https://wingo-ui.com/de/blog/react-controllable-state-hook). Mehr Artikel zum selben Stack findest du unter [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind).

## Komponenten in diesem Artikel

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

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

---

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