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:
"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:
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:
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:
"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-proofCatch it in CI
Add a mobile viewport to your test matrix:
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