El peligro en SSR
La palabra hidratación tiene dos dueños que en el renderizado en servidor colisionan de frente. Uno es el de la lección anterior: reconstituir el estado guardado dentro de un store. El otro es el del framework: el proceso por el que el cliente toma el HTML que el servidor ya pintó y le engancha la interactividad, reconciliando el árbol vivo con el árbol muerto que llegó por la red. El desastre nace cuando esos dos mundos discrepan. El servidor no tiene acceso a `localStorage`, ni a `window`, ni a la preferencia de tema del usuario; renderiza un estado neutro por fuerza. El cliente, en su primer render, lee el estado persistido y produce un árbol distinto. El framework compara ambos, no coinciden, y dispara el temido mismatch de hidratación: en el mejor caso un aviso en consola y un parpadeo, en el peor un árbol descartado y una interfaz rota. Esta lección explica por qué el servidor está condenado a la ignorancia, y presenta las tres formas de desactivar la bomba: enseñar al servidor mediante cookies, diferir al cliente con un render en dos pasos, o rendirse y silenciar el aviso donde la divergencia es inevitable.
Hasta ahora la persistencia vivía enteramente en el navegador: escribías en el disco, leías al arrancar, y el único juez era el propio cliente. El renderizado en servidor rompe esa comodidad porque introduce un segundo testigo que pinta la pantalla antes que el cliente y sin la menor idea de lo que el usuario guardó. El servidor genera el HTML inicial contra un estado que solo él conoce —neutro, por defecto, ignorante de toda preferencia local—, lo envía por la red, y el navegador lo recibe ya dibujado. Entonces el framework arranca su propia hidratación, la suya, la que engancha los manejadores de eventos al DOM existente, y para ello vuelve a renderizar el primer fotograma en el cliente esperando que coincida clavado con lo que el servidor mandó. Si en ese primer render el cliente consulta localStorage y descubre que el usuario quería tema oscuro, produce un árbol que el servidor jamás pudo anticipar. Los dos árboles no encajan, y el framework tiene que elegir entre malas opciones. El mismatch de hidratación no es un bug tuyo: es la consecuencia estructural de tener dos fuentes de verdad que deben coincidir en el instante cero y que, por construcción, saben cosas distintas.
- Distinguir las dos hidrataciones que colisionan en SSR: la del estado persistido y la del framework sobre el DOM.
- Explicar por qué el servidor no puede conocer
localStorage,windowni las preferencias locales del cliente. - Diagnosticar un mismatch de hidratación por sus síntomas: aviso en consola, parpadeo y árbol descartado.
- Aplicar las tres estrategias de defensa: cookie legible por el servidor, render en dos pasos y silenciado explícito.
Dos hidrataciones que colisionan
En un proyecto Astro con islas de React, Vue o Solid conviven dos usos de la palabra que conviene separar sin piedad. La hidratación del framework es el acto de tomar el HTML estático que el servidor generó y hacerlo interactivo, adjuntando listeners y reconstruyendo el estado interno de los componentes sin volver a crear el DOM. La rehidratación de la persistencia es lo que viste en la lección anterior: cargar el estado guardado en el store. El choque ocurre cuando la segunda alimenta el primer render de la primera.
El framework asume un contrato sagrado: el primer render del cliente debe producir exactamente el mismo árbol que el servidor serializó. Sobre esa igualdad monta su optimización, porque le permite no recrear nodos sino solo animarlos. En cuanto el cliente lee estado que el servidor no tenía, rompe el contrato.
Merece subrayarse por qué el framework es tan estricto con esa igualdad. La promesa del SSR es entregar HTML útil de inmediato y luego adoptarlo sin reconstruirlo, reutilizando los nodos que ya existen y limitándose a colgarles la interactividad. Esa adopción solo es segura si el árbol que el cliente calcula encaja nodo a nodo con el recibido; si no encaja, el framework ya no puede adoptar y tiene que rehacer, que es justo el trabajo que el SSR prometía ahorrar. El mismatch, entonces, no es un aviso cosmético: es la anulación silenciosa de la ventaja por la que pagaste la complejidad de renderizar en el servidor.
Por qué el servidor está condenado a la ignorancia
El servidor renderiza en un entorno amputado a propósito. No hay window, no hay document, no hay localStorage ni IndexedDB, no hay matchMedia para consultar si el sistema prefiere tema oscuro. Todo eso son APIs del navegador, y el servidor es un proceso de Node o un worker que jamás las tuvo. Lo único que el servidor puede leer del cliente es lo que viaja en la petición HTTP: la URL, las cabeceras y, sobre todo, las cookies.
Por eso los mismatches más comunes son siempre los mismos sospechosos: el tema leído de localStorage, el idioma detectado en el cliente, la interfaz que depende de si hay sesión, el formateo de fechas según la zona horaria del navegador, o cualquier identificador aleatorio generado en render. En todos, el servidor pinta una cosa y el cliente, con más información, pinta otra.
// BOMBA: el servidor no puede leer localStorage. Render neutro alli,
// tema real aqui, y los arboles no coinciden.
function Tema() {
const oscuro = localStorage.getItem('tema') === 'oscuro' // undefined en servidor
return <html data-tema={oscuro ? 'oscuro' : 'claro'} />
}
Conviene saber que el síntoma varía según el framework y su versión, lo que despista al depurar. React desde la versión 18, al detectar la discrepancia durante la hidratación, descarta el HTML del servidor en ese subárbol y lo vuelve a renderizar en el cliente: arregla la vista pero regala el beneficio del SSR justo donde falla. Versiones antiguas parcheaban el DOM en silencio, dejando atributos inconsistentes y bugs sutilísimos. Vue y Solid emiten sus propios avisos y toman sus propias decisiones. La moraleja es no confiar en que la ausencia de un error visible signifique ausencia de mismatch: puede estar corrompiendo la vista sin decir nada.
flowchart TD
S[servidor renderiza estado neutro] --> H[HTML enviado al navegador]
H --> C[cliente hidrata y lee localStorage]
C --> Q{coincide con el HTML del servidor}
Q -->|si| OK[hidratacion limpia sin recrear DOM]
Q -->|no| M[mismatch: aviso, parpadeo o arbol descartado]
style OK fill:#a6e3a1,color:#11111b
style M fill:#f38ba8,color:#11111bCómo desactivar la bomba
Hay tres caminos, y elegir depende de si puedes trasladar el conocimiento al servidor o si estás obligado a diferir el cliente.
Enseñar al servidor
Mueve la verdad a una cookie que el servidor lea en la petición. Pinta el árbol correcto desde el primer byte y el mismatch nunca ocurre. La mejor opción para el tema, el idioma o la sesión.
Render en dos pasos
Primer render neutro, idéntico al del servidor; tras montar, un efecto lee el dato local y corrige. Aceptas un parpadeo a cambio de un HTML consistente. Para datos que solo existen en el cliente.
Silenciar el aviso
suppressHydrationWarning en el nodo mínimo donde la divergencia es legítima, como una fecha en la zona horaria del cliente. No repara nada: declara que la diferencia es esperada y no debe alarmar.
Compuerta client-only
No renderizar en el servidor en absoluto. Elimina el mismatch de raíz pero sacrifica el HTML inicial, el SEO y el pintado temprano. Último recurso, solo para lo que no aporta al primer fotograma.
El primero, el mejor cuando aplica, es enseñar al servidor. Si mueves la fuente de verdad de localStorage a una cookie, el servidor la recibe en la petición y renderiza ya el árbol correcto. El tema es el caso de manual: guarda la preferencia en una cookie, léela en el servidor, pinta el data-tema bien desde el primer byte, y no hay mismatch porque nunca hubo dos verdades.
// SOLUCION 1: cookie que el servidor SI puede leer.
const oscuro = leerCookie(peticion, 'tema') === 'oscuro' // funciona en servidor
return <html data-tema={oscuro ? 'oscuro' : 'claro'}>{ninos}</html>
Cuando ni siquiera una cookie es viable pero necesitas evitar el parpadeo del tema a toda costa, queda un truco clásico: un pequeño script bloqueante en el head que lee localStorage y fija el atributo en el elemento html antes de que el navegador pinte el primer píxel. Al ejecutarse antes del render no hay parpadeo, y al no formar parte del árbol que el framework hidrata, tampoco provoca mismatch.
<!-- En el head, antes de cualquier render del framework. -->
<script>
const t = localStorage.getItem('tema')
if (t) document.documentElement.dataset.tema = t
</script>
Es la técnica que usan casi todos los sitios con tema oscuro sin destello. Paga un coste minúsculo —un script síncrono que bloquea el primer pintado unos microsegundos— pero compra la ausencia total de parpadeo sin cookie, y por eso se ha vuelto el estándar de facto para la preferencia de tema.
El segundo camino es el render en dos pasos, para cuando el dato solo existe en el cliente. La idea es rendirse en el primer render y coincidir con el servidor pintando el estado neutro; después, ya montado, un efecto lee localStorage y actualiza. Aceptas un parpadeo a cambio de un HTML consistente.
// SOLUCION 2: primer render neutro como el servidor, luego corrige.
function Tema() {
const [montado, setMontado] = useState(false)
const [oscuro, setOscuro] = useState(false) // neutro, igual que el servidor
useEffect(() => {
setOscuro(localStorage.getItem('tema') === 'oscuro')
setMontado(true)
}, [])
return <html data-tema={oscuro ? 'oscuro' : 'claro'} data-listo={montado} />
}
El tercer camino es silenciar el aviso donde la divergencia es legítima e inevitable, como una fecha formateada en la zona horaria del cliente. React ofrece suppressHydrationWarning en el nodo concreto; no repara nada, solo le dice al framework que en ese punto la diferencia es esperada y no debe alarmar. Es un bisturí, no una venda: úsalo en el nodo mínimo, jamás en un subárbol entero.
Existe un cuarto patrón tentador: no renderizar nada hasta que el componente esté montado en el cliente, devolviendo null en el servidor y en el primer render. Elimina el mismatch de raíz, sí, pero al precio de que ese subárbol deja de existir en el HTML inicial. Pierdes el SEO, el contenido tarda más en aparecer y regalas justo el beneficio por el que adoptaste SSR. Es aceptable para un widget que de todos modos no aporta al primer pintado, pero usarlo sobre contenido principal es curar la fiebre matando al paciente.
Astro y las islas: dónde ocurre la hidratación
En Astro el problema tiene una geografía precisa, porque no toda la página se hidrata: solo las islas que marcas con una directiva client:. Eso acota el mismatch a fronteras conocidas y te concede una palanca que un framework monolítico no ofrece: decidir, isla por isla, si se renderiza en el servidor.
Una isla client:load o client:visible se pinta en el servidor y luego se hidrata en el cliente, de modo que está sujeta a todo lo dicho: su primer render de cliente debe coincidir con el HTML servido. Una isla client:only, en cambio, no se renderiza jamás en el servidor; Astro emite un marcador vacío y el componente se monta entero en el cliente. Con client:only el mismatch es imposible por construcción, porque no existe HTML de servidor con el que discrepar.
Esa inmunidad cuesta lo mismo que la compuerta del apartado anterior: pierdes el contenido en el HTML inicial y con él el SEO y el pintado temprano de esa isla. La regla práctica es reservar client:only para islas intrínsecamente dependientes del navegador —un mapa, un editor de texto rico, un panel que lee localStorage y no aporta al primer pintado— y mantener client:load con estado neutro y corrección diferida para todo lo que sí deba existir en el HTML servido.
Una ventaja infravalorada de la arquitectura de islas es que un mismatch en un widget no contamina la página entera: solo esa isla se ve afectada, y el resto del HTML servido permanece intacto e interactivo. Aprovéchalo para aislar el estado problemático —el que depende del cliente— en islas pequeñas y bien delimitadas, en lugar de dejarlo repartido por un árbol grande donde un solo dato local obligue a diferir componentes que no lo necesitaban.
Cuesta ver el mismatch de hidratación como algo más que un aviso molesto que hay que hacer callar, pero es una de las lecciones más profundas sobre el estado distribuido que ofrece el frontend. Lo que el aviso te está gritando es que tienes dos fuentes de verdad —el estado que el servidor conoció al pintar y el estado que el cliente descubre al arrancar— y que ambas pretenden describir el mismo instante cero de la misma interfaz. Cuando discrepan, el framework no puede saber cuál tiene razón, porque las dos son ciertas en su propio contexto: el servidor no mentía al no conocer tu tema, y el cliente no miente al leerlo de localStorage. El problema no es que una esté mal, es que existen dos y deben ser una. De ahí que las soluciones no sean trucos sino elecciones ontológicas sobre dónde debe vivir la verdad. Mover el dato a una cookie es decidir que la verdad nace en el servidor y el cliente la hereda. El render en dos pasos es decidir que la verdad nace en el cliente y el servidor pinta un lugar reservado que luego se llena. El silenciado es aceptar que en ese punto concreto habrá siempre dos verdades y que su diferencia es tolerable. No hay una cuarta vía mágica que evite elegir, porque el problema es de teoría de la información, no de API: no puedes hacer coincidir dos observadores que, por construcción, observaron cosas distintas, sin darle a uno la información del otro o sin posponer la observación de uno hasta después del acuerdo.
- En una isla hidratada, lee el tema de
localStoragedirectamente en render y observa en consola el aviso de mismatch junto al parpadeo en la primera carga. - Identifica en tu aplicación los tres sospechosos habituales: preferencias locales, interfaz dependiente de sesión y fechas formateadas por zona horaria.
- Resuelve el tema moviéndolo a una cookie que el servidor lea, y confirma que el HTML llega ya con el atributo correcto sin parpadeo.
- Resuelve un dato solo-cliente con el render en dos pasos usando una bandera
montado, y razona qué parpadeo aceptas a cambio. - Aplica
suppressHydrationWarninga un único nodo de fecha y verifica que el aviso desaparece solo ahí, sin ocultar otros mismatches reales. - Escribe para cada sospechoso de tu lista qué estrategia elegiste y por qué, dejando explícito dónde decidiste que vive la verdad.