If your first render depends on document, matchMedia, location.hash, localStorage or a cookie, you are one differing value away from a hydration mismatch — those APIs are absent during SSR and already real on the client's first render. tsc, ESLint and a Node-environment unit suite all pass anyway, because none of them ever perform a hydration render. Two honest fixes: seed after mount (simple, one frame of flash), or read the value server-side (no flash, needs a cookie round-trip).
The symptom
Admin sidebar. It can be collapsed to an icon rail, and the choice persists. Navigate to another admin page and the rail is expanded for a beat, then snaps closed — plus a hydration warning in the console.
Everything was green. tsc --noEmit: exit 0. eslint: exit 0. 327 unit tests passing. The bug was only visible with my own eyes, in a browser, on the second page load.
What I assumed
I assumed "client-only API" meant "unavailable until useEffect runs". So reading document.cookie directly during render felt safe-ish: the server would take the typeof document === "undefined" branch, the client would take the real branch, and React would sort out the difference.
That is wrong in a specific way, and the specific way is the whole point.
The actual mechanism
Server-rendered HTML arrives. React then does a hydration render — it re-runs your component tree on the client and expects the output to match the received HTML exactly. That render happens in a real browser. document exists. window.matchMedia exists. location.hash is populated. localStorage is readable.
So the branch you wrote as "the server branch" is only taken once, during SSR. On the very first client render — the one that has to match — you get the real value. If the real value differs from the server default, the trees diverge: React either warns and patches the text, or discards the server HTML and re-renders on the client from the nearest Suspense boundary — the whole root, if there isn't one. The flash you see is that correction.
The naive version of the cookie banner reads like this, and it type-checks perfectly:
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;Server: "ssr" → renders nothing. First client render: "none" → renders a fixed banner. Mismatch, guaranteed, on every first visit.
Fix (a): seed after mount
Start from a value the server can also produce, then correct it in an effect. From levain-template/src/components/sections/auth/AccountDashboard.tsx:
// Start on "orders" so the server render and the first client render agree
// (window.location.hash is unavailable on the server). A `#favorites` (etc.)
// deep-link is honoured just after mount via the effect below, which keeps
// hydration in sync at the cost of one imperceptible tab switch.
const [tab, setTab] = useState<Tab>("orders");
useEffect(() => {
const hash = window.location.hash.replace("#", "");
const valid: Tab[] = ["profile", "orders", "addresses", "favorites"];
if ((valid as string[]).includes(hash)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only post-mount seed to keep SSR/CSR hydration in sync
setTab(hash as Tab);
}
}, []);Note the disable comment. On eslint-config-next 16.2.10 (which pulls in eslint-plugin-react-hooks 7), react-hooks/set-state-in-effect flags exactly this shape, because in the general case it is a wasted render. Here it is deliberate, so it gets a comment explaining why.
When you don't want to argue with the linter, useSyncExternalStore is the same idea as a primitive — getServerSnapshot is literally "the value to use for SSR and the hydration render":
// booking-template/src/lib/useIsMobile.ts
function subscribe(onChange: () => void): () => void {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}React uses getServerSnapshot for the hydration pass, then switches to getSnapshot immediately after. Same shape, no lint fight, and you get change subscription for free. The cookie banner in booking-template/src/components/landing/CookieBanner.tsx uses a sentinel instead of a boolean:
function readConsent(): string {
if (typeof document === "undefined") return "ssr";
const hit = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
return hit ? (hit.split("=")[1] ?? "none") : "none";
}
const getServerSnapshot = () => "ssr";"ssr" renders nothing, so the hydration render is byte-identical to the server's. The banner appears one tick later. For a consent strip that is fine.
Fix (b): read it server-side
For the sidebar it was not fine — a rail that visibly un-collapses on every navigation looks broken. So the value moves to a cookie the server can read:
// booking-template/src/lib/admin/collapsed.ts
export const ADMIN_COLLAPSED_COOKIE = "vs-admin-collapsed";
export async function getAdminCollapsed(): Promise<boolean> {
const { cookies } = await import("next/headers");
return (await cookies()).get(ADMIN_COLLAPSED_COOKIE)?.value === "1";
}The admin layout calls it, passes the value through a context provider, and the sidebar seeds its own state from it:
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());Now SSR and hydration both compute true. No mismatch, no flash. The same seam handles theme: the locale layout reads vs-theme / vs-mode from the cookie jar and stamps data-theme / data-mode straight onto <html>, so the first painted frame is already the right theme.
The trade-off is real and worth stating plainly. (a) is local — one file, no server changes, and it can flash. (b) never flashes but requires the value to live in a cookie, which means a client write, a navigation or reload to take effect, and a dynamic render on that route. Use (b) when the flash is layout-shifting or brand-visible; use (a) for everything else.
There's a third option people forget: don't branch in JS at all. The landing Reveal keeps its hidden state in CSS gated on html.has-js and lets prefers-reduced-motion disable it in a media query — no useReducedMotion call, so nothing to mismatch.
Why CI was green
Because nothing in CI hydrates.
booking-template/vitest.config.ts says environment: "node" and include: ["tests/**/*.test.ts"] — .ts only, no .tsx. Those 327 tests exercise pure helpers: availability math, coupon rules, i18n key coverage. They cannot observe a hydration mismatch, because they never mount a tree twice. tsc sees both branches as well-typed. ESLint sees a legal typeof guard.
Green CI told me the code was well-formed. It said nothing about whether the feature worked.
The checklist
Every one of these is undefined/absent during SSR and real during the client's first render. If one of them decides what you return, stop and pick (a) or (b):
document— includingdocument.cookiewindow,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)and anything built on it,useReducedMotionincludednavigator(language,userAgent,onLine)Date.now()/new Date()when it feeds rendered outputMath.random()and any non-seeded id
Grep for those inside render bodies and useState initializers. That grep found four of these in my own code in one afternoon.
This is the pattern I ship in the booking template, live demo at booking.violettadev.com.