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