# Fix the useMediaQuery Next.js hydration error

> A useMediaQuery Next.js hydration error happens when render code reads matchMedia or the window size. Use CSS media queries, or read the value after mount.

Source: https://hydration.jscrate.dev/docs/causes/media-query
Last updated: 2026-09-18

A useMediaQuery Next.js hydration error happens when a component reads
`matchMedia`, `innerWidth` or the screen size while it renders. The server has
no screen and renders a guess (usually desktop); a phone renders the mobile
branch during hydration, and the HTML differs. Let CSS media queries decide
layout, or read the query after mount.

## Symptoms

```text
Hydration failed because the server rendered text didn't match the client.
Warning: Text content did not match. Server: "Desktop layout" Client: "Mobile layout"
Warning: Prop `className` did not match. Server: "menu-desktop" Client: "menu-mobile"
Warning: Expected server HTML to contain a matching <nav> in <header>.
```

Desktop visitors see nothing wrong. Every phone and tablet hits the mismatch.

hydration-proof reports text as [HP1001](https://hydration.jscrate.dev/docs/issues/hp1001), class names as
[HP1004](https://hydration.jscrate.dev/docs/issues/hp1004) and swapped elements as
[HP1007](https://hydration.jscrate.dev/docs/issues/hp1007), with the cause **Screen size or media query read
during render**. In the package's test suite it names the cause with 89%
confidence.

## Why the useMediaQuery Next.js hydration error happens

Most `useMediaQuery` hooks, and hand-written checks, evaluate the query while
the component renders:

```tsx title="menu.tsx"
"use client";

export function Menu() {
  // Before: false on the server, true on a phone during hydration
  const mobile =
    typeof window !== "undefined" &&
    window.matchMedia("(max-width: 600px)").matches;
  return mobile ? <MobileMenu /> : <DesktopMenu />;
}
```

The server cannot evaluate `(max-width: 600px)`, `innerWidth`, `screen.width`
or `(prefers-reduced-motion: reduce)`, so it picks a default. The first client
render evaluates the real query. For `prefers-color-scheme`, see the
[theme guide](https://hydration.jscrate.dev/docs/causes/theme): it has its own fixes.

## How to fix it

### Let CSS decide the layout

When the difference is only layout, render both versions and let a CSS media
query hide one. The server HTML and the client render are identical, and the
right version shows before any JavaScript runs:

```tsx title="menu.tsx"
export function Menu() {
  return (
    <>
      <MobileMenu className="md:hidden" />
      <DesktopMenu className="hidden md:block" />
    </>
  );
}
```

The same works with plain CSS (`@media (min-width: 768px)`). This is the only
fix with no flash on first paint.

### Use a media query hook with a server snapshot

When the component really must know the answer in JavaScript, subscribe with
`useSyncExternalStore`. It uses the server snapshot during server rendering and
hydration, then switches to the real value and keeps it updated:

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

export function useMediaQuery(query: string, serverValue = false) {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const list = window.matchMedia(query);
      list.addEventListener("change", onChange);
      return () => list.removeEventListener("change", onChange);
    },
    [query]
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => serverValue
  );
}
```

Pick the server value your most common visitor gets, since everyone else sees
one render with it. Library hooks have an equivalent switch: usehooks-ts'
`useMediaQuery` takes `{ defaultValue, initializeWithValue: false }` for
server-rendered pages.

### Read the query after mount

A plain effect works too, and fits code that only needs the value once:

```tsx title="reduced-motion.tsx"
"use client";

import { useEffect, useState } from "react";

export function useReducedMotion() {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  }, []);

  return reduced;
}
```

[useEffect and two-pass rendering](https://hydration.jscrate.dev/docs/guides/useeffect-two-pass-rendering)
explains the extra render and how to keep it from shifting the layout.

## Catch it with ESLint

[`no-match-media-in-render`](https://hydration.jscrate.dev/docs/rules/no-match-media-in-render) reports
`matchMedia` calls in render code, including state initializers and calls
behind a `typeof window` check. `innerWidth`, `screen` and `devicePixelRatio`
are reported by [`no-browser-global-in-render`](https://hydration.jscrate.dev/docs/rules/no-browser-global-in-render),
and [`require-stable-server-snapshot`](https://hydration.jscrate.dev/docs/rules/require-stable-server-snapshot)
keeps `matchMedia` out of `getServerSnapshot`.

```bash
npm install -D eslint-plugin-hydration-proof
```

## Catch it in CI

hydration-proof tests a desktop-sized viewport unless a scenario sets another.
Add a mobile scenario, or a viewport axis to the matrix:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  matrix: {
    viewport: ["desktop", "mobile"],
  },
});
```

Findings are HP1001, HP1004 or HP1007, and the report names the viewport that
separates failing pages from passing ones. With `--probe`, hydration-proof
reloads the page with only the viewport swapped between desktop and mobile; if
the value follows it, the viewport is the proven cause. See
[probes](https://hydration.jscrate.dev/docs/probes).

```bash
npx hydration-proof test --probe
```

## Related

- [Dark mode and theme mismatches](https://hydration.jscrate.dev/docs/causes/theme)
- [Browser-only APIs in render](https://hydration.jscrate.dev/docs/causes/browser-api)
- [The no-match-media-in-render rule](https://hydration.jscrate.dev/docs/rules/no-match-media-in-render)
- [Testing viewports with the environment matrix](https://hydration.jscrate.dev/docs/environment-matrix)
- [HP1004: class name differs](https://hydration.jscrate.dev/docs/issues/hp1004)
