Hydration Proof

Search documentation

Find a page or section

Fix the useMediaQuery Next.js hydration error

The server has no screen, so it guesses.

A useMediaQuery Next.js hydration error happens when render code chooses a layout using the browser's screen size. The server cannot measure a screen, so it renders a fallback. A phone may render a different branch during hydration. Prefer CSS for layout, or read the media query after mount.

Symptoms

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

This can be easy to miss when you test only a desktop viewport.

hydration-proof reports changed text as HP1001, classes as HP1004, and changed elements as HP1007.

Why the useMediaQuery Next.js hydration error happens

Some useMediaQuery hooks evaluate the query during render:

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 uses a default; the browser uses its real viewport. For prefers-color-scheme, see the theme guide.

How to fix it

Let CSS decide the layout

When the difference is only visual, render stable markup and let CSS choose what to show. The browser can apply CSS before JavaScript runs:

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

The same approach works with plain CSS media queries.

Use a media query hook with a server snapshot

If JavaScript needs the result, useSyncExternalStore can use a stable server snapshot during hydration and the real media query afterwards:

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
  );
}

Choose a sensible server default; visitors with another screen size may see one update after hydration. In usehooks-ts, set { defaultValue, initializeWithValue: false } for server-rendered pages.

Read the query after mount

If you only need the result once, an effect is simpler:

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;
}

See two-pass rendering for how to avoid a visible jump.

Catch it with ESLint

no-match-media-in-render flags matchMedia in render code. no-browser-global-in-render covers values such as innerWidth, while require-stable-server-snapshot checks the server snapshot callback.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

Add a mobile viewport to your test matrix:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  matrix: {
    viewport: ["desktop", "mobile"],
  },
});

The report shows which viewport produced the finding. With --probe, it swaps the browser viewport while keeping other factors stable. See probes.

npx hydration-proof test --probe