Si tu primer render depende de document, matchMedia, location.hash, localStorage o una cookie, estás a un valor distinto de un hydration mismatch — esas APIs no existen durante el SSR y ya son reales en el primer render del cliente. tsc, ESLint y una suite de tests unitarios en entorno Node pasan igual, porque ninguno de ellos llega a hacer nunca un render de hydration. Dos arreglos honestos: sembrar el valor después del mount (simple, un frame de parpadeo), o leer el valor en el servidor (sin parpadeo, exige un viaje de ida y vuelta con cookie).
El síntoma
Sidebar de admin. Se puede colapsar a una barra de iconos, y la elección persiste. Navegas a otra página de admin y la barra queda expandida por un instante, luego se cierra de golpe — más un hydration warning en la consola.
Todo estaba en verde. tsc --noEmit: exit 0. eslint: exit 0. 327 tests unitarios pasando. El bug solo era visible con mis propios ojos, en un navegador, en la segunda carga de página.
Lo que asumí
Asumí que "API solo de cliente" significaba "no disponible hasta que corre useEffect". Así que leer document.cookie directamente durante el render se sentía más o menos seguro: el servidor tomaría la rama typeof document === "undefined", el cliente tomaría la rama real, y React resolvería la diferencia.
Eso está mal de una forma específica, y esa forma específica es todo el punto.
El mecanismo real
Llega el HTML renderizado en el servidor. React hace entonces un render de hydration — vuelve a ejecutar tu árbol de componentes en el cliente y espera que la salida coincida exactamente con el HTML recibido. Ese render ocurre en un navegador real. document existe. window.matchMedia existe. location.hash está poblado. localStorage se puede leer.
O sea que la rama que escribiste como "la rama del servidor" solo se toma una vez, durante el SSR. En el primerísimo render del cliente — el que tiene que coincidir — obtienes el valor real. Si el valor real difiere del default del servidor, los árboles divergen: React o bien avisa y parcha el texto, o bien descarta el HTML del servidor y vuelve a renderizar en el cliente desde el Suspense boundary más cercano — la raíz entera, si no hay ninguno. El parpadeo que ves es esa corrección.
La versión ingenua del cookie banner se lee así, y pasa el type-check a la perfección:
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;Servidor: "ssr" → no renderiza nada. Primer render del cliente: "none" → renderiza un banner fijo. Mismatch, garantizado, en cada primera visita.
Arreglo (a): sembrar después del mount
Parte de un valor que el servidor también pueda producir, y corrígelo en un effect. De 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);
}
}, []);Fíjate en el comentario de disable. En eslint-config-next 16.2.10 (que arrastra eslint-plugin-react-hooks 7), react-hooks/set-state-in-effect marca exactamente esta forma, porque en el caso general es un render desperdiciado. Aquí es deliberado, así que lleva un comentario explicando por qué.
Cuando no quieres pelear con el linter, useSyncExternalStore es la misma idea pero como primitiva — getServerSnapshot es literalmente "el valor a usar para el SSR y para el render de hydration":
// 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 usa getServerSnapshot para la pasada de hydration, y cambia a getSnapshot inmediatamente después. Misma forma, sin pelea con el lint, y encima te llevas la suscripción a cambios gratis. El cookie banner en booking-template/src/components/landing/CookieBanner.tsx usa un centinela en vez de un booleano:
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" no renderiza nada, así que el render de hydration es byte a byte idéntico al del servidor. El banner aparece un tick después. Para una franja de consentimiento eso está bien.
Arreglo (b): leerlo en el servidor
Para el sidebar no estaba bien — una barra que visiblemente se des-colapsa en cada navegación se ve rota. Así que el valor se muda a una cookie que el servidor pueda leer:
// 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";
}El layout de admin la llama, pasa el valor por un context provider, y el sidebar siembra su propio estado desde ahí:
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());Ahora SSR e hydration calculan ambos true. Sin mismatch, sin parpadeo. La misma costura resuelve el tema: el layout de locale lee vs-theme / vs-mode del tarro de cookies y estampa data-theme / data-mode directo en el <html>, así que el primer frame pintado ya trae el tema correcto.
El trade-off es real y vale la pena decirlo sin rodeos. (a) es local — un archivo, sin cambios en el servidor, y puede parpadear. (b) nunca parpadea pero exige que el valor viva en una cookie, lo que significa una escritura en el cliente, una navegación o recarga para que surta efecto, y un render dinámico en esa ruta. Usa (b) cuando el parpadeo mueve el layout o es visible para la marca; usa (a) para todo lo demás.
Hay una tercera opción que la gente olvida: no ramificar en JS en absoluto. El Reveal de la landing guarda su estado oculto en CSS condicionado a html.has-js y deja que prefers-reduced-motion lo desactive en una media query — sin llamada a useReducedMotion, así que no hay nada que pueda divergir.
Por qué el CI estaba en verde
Porque nada en CI hidrata.
booking-template/vitest.config.ts dice environment: "node" e include: ["tests/**/*.test.ts"] — solo .ts, nada de .tsx. Esos 327 tests ejercitan helpers puros: matemática de disponibilidad, reglas de cupones, cobertura de claves i18n. No pueden observar un hydration mismatch, porque nunca montan un árbol dos veces. tsc ve ambas ramas bien tipadas. ESLint ve un guard typeof legal.
Un CI en verde me dijo que el código estaba bien formado. No dijo nada sobre si la feature funcionaba.
La checklist
Cada uno de estos es undefined/inexistente durante el SSR y real durante el primer render del cliente. Si uno de ellos decide lo que retornas, detente y elige (a) o (b):
document— incluidodocument.cookiewindow,window.location.search,window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)y cualquier cosa construida encima,useReducedMotionincluidonavigator(language,userAgent,onLine)Date.now()/new Date()cuando alimenta la salida renderizadaMath.random()y cualquier id no sembrado
Haz grep de eso dentro de los cuerpos de render y de los inicializadores de useState. Ese grep encontró cuatro de estos en mi propio código en una tarde.
Este es el patrón que envío en el booking template, demo en vivo en booking.violettadev.com.