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

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, class names as HP1004 and swapped elements as 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:

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

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:

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:

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 explains the extra render and how to keep it from shifting the layout.

Catch it with ESLint

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, and require-stable-server-snapshot keeps matchMedia out of getServerSnapshot.

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:

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.

npx hydration-proof test --probe