background blurbackground mobile blur

1/1/1970

Cómo implementé SSG en 2 días

¡Hola! Hace un año pensaba que esto era imposible. Pero acabo de terminar de implementar Static Site Generation (SSG) para Foony en 2 días, y estoy bastante contento con cómo ha quedado. Tampoco es la primera vez que intento resolver el SSG para Foony. En el pasado he mirado NextJS, Vike, Astro, Gatsby y unas cuantas soluciones más. Incluso tuve un intento fallido con NextJS, pero me topé con la complejidad de la SPA de Foony y sus miles de archivos. La migración habría sido una pesadilla y habría llevado meses. Además, habría añadido complejidad para todo el mundo que trabaja en la web, porque tendrían que aprender NextJS y todas sus manías.

Yo quería algo ligero y fácil de implementar. Algo que nos permitiera seguir escribiendo código igual que hasta ahora, sin tener que estar pensando en el SSG (con la excepción de useMediaQuery, ahí no hay mucha escapatoria). Más abajo cuento por qué me quedé con una solución a medida, los problemas concretos con los que me encontré (sobre todo con los límites de Suspense de React) y cómo los fui resolviendo.

¿Por qué no usar soluciones estándar?

Cuando empecé a mirar cómo añadir SSG a Foony, lo primero que consideré fue NextJS (el estándar de la industria), Vike y Astro.

NextJS: demasiada migración

NextJS es potente, pero habría requerido una migración enorme de la SPA actual de Foony en React. Tenemos miles de archivos, lógica de enrutado bastante compleja y mucha infraestructura propia. Migrar a NextJS habría significado:

  • Reescribir todo nuestro sistema de rutas
  • Cambiar la forma en la que cargamos juegos y componentes
  • Meses de trabajo solo para llegar al mismo punto en funcionalidades
  • Posibles cambios que rompan cosas a los usuarios
  • Cambiar cómo manejamos las imágenes
  • Builds significativamente más lentas (posiblemente entre 5 y 30 minutos; no tengo cifras concretas que lo respalden aparte de este debate de hace 5 años en GitHub)
  • Que todo el equipo tuviera que aprender algo nuevo (NextJS) y una velocidad de desarrollo más lenta para siempre
  • Tener que migrar el código cada vez que NextJS decida introducir cambios incompatibles

Incluso empecé un intento con NextJS, pero enseguida vi que el coste de la migración era demasiado alto. La complejidad no compensaba.

Vike: complejidad similar

Vike (antes vite-plugin-ssr) tenía problemas parecidos. Aunque es más flexible que NextJS, aun así habría requerido una reestructuración importante de nuestro código. La curva de aprendizaje y el esfuerzo de migración no compensaban los beneficios.

Astro: arquitectura equivocada

Astro es genial para sitios con mucho contenido, pero Foony es una plataforma compleja de juegos multijugador. Necesitamos actualizaciones en tiempo real, conexiones WebSocket y componentes dinámicos de React. La arquitectura de Astro simplemente no encaja con lo que estamos construyendo.

La solución: SSG a medida

Animado por el enfoque de "SSG falso" que implementé unos días antes después de i18n, me decidí por una solución pequeña, ligera y a medida para el SSG de Foony.

Mi enfoque de "SSG falso" consistía en extraer el contenido de las entradas del blog de las páginas que las tenían (rutas /posts y páginas de juegos) y colocarlo exactamente donde el cliente lo renderizaría, específicamente para que los motores de búsqueda y los LLM entendieran mejor Foony. También aplicaba esquema ld+json y algunos detalles pequeños de SEO.

El enfoque es sencillo:

  1. Construir encima de la SPA de React existente: nada de migraciones, solo añadir generación SSG en tiempo de build.
  2. Usar renderToReadableStream: la API de SSR en streaming de React 18 maneja Suspense de forma nativa.
  3. Generar archivos HTML estáticos: prerenderizar rutas en el build y servirlas como archivos estáticos, usando nuestro SitemapGenerator para obtener la lista de rutas.
  4. Cambios mínimos en el código existente: la mayoría de componentes funcionan tal cual.

La implementación principal está en client/src/generators/GenerateShellSsgFromSitemap.ts. Lee un sitemap, renderiza cada ruta usando renderToReadableStream de React y escribe el HTML en archivos estáticos. Simple, justo como me gusta.

Además ha resultado bastante rápido. Unas 2.800 rutas renderizadas en 10 segundos. Nada mal. Es significativamente más rápido que NextJS, Gatsby y Astro. <img alt="Log de consola de SSG mostrando el tiempo que tarda" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Podría hablar horas sobre la simplicidad. Aunque no te vaya a conseguir un ascenso en una gran empresa por "falta de complejidad", el código simple es bonito, mantenible y en general mucho mejor para la velocidad de desarrollo. Es algo que admiro mucho de los principios Zen.

El problema de los límites de Suspense

Ya tenía SSG, el contenido aparecía en el HTML... pero mis páginas estaban en blanco. ¿Cómo podía ser? <img alt="Página en blanco con SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Resulta que renderToReadableStream sigue dejando límites de Suspense, incluso si haces await stream.allReady. Mi intuición es que esto pasa porque es un "stream" y está pensado para mandarse al cliente a medida que se van recibiendo los bytes.

Lo que genera React

Cuando usas renderToReadableStream con Suspense, React saca un HTML como este:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

El <template id="B:0"> es un marcador de dónde debería ir el contenido. El <div hidden id="S:0"> contiene el contenido renderizado de verdad. El B:0 y el S:0 coinciden por número (índice empezando en 0).

Sin JavaScript, los motores de búsqueda (te miro a ti, Bing) y los LLM verían una página casi en blanco, solo con la plantilla como placeholder. Eso va justo en contra de todo lo que queremos conseguir con el SSG.

No vi ninguna forma limpia de quitar estos límites de Suspense, así que mi solución fue escribir unos tests y una función resolveSuspenseBoundaries para intercambiarlos. Era más rápido que parsear el HTML y ejecutar el script con algo como JSDOM. Y, lo más importante, era un requisito para lo que yo quería: un sitio legible para motores de búsqueda y LLM sin JavaScript, pero con soporte para límites de Suspense e hidratación en el cliente.

Probando la transformación

Empecé escribiendo tests para la transformación, cogiendo ejemplos del DOM de lo que tenía (con JavaScript desactivado) y de lo que quería (con JavaScript activado). Metí esos ejemplos en un LLM y le dejé encargarse de generar los tests, que es algo que se le da bastante bien. Esos tests están en client/src/generators/ssr/renderRoute.test.ts y se aseguran de que la transformación funcione bien. Cubren:

  • Reemplazo sencillo de límites (listado de blogs)
  • Límites complejos con contenido entre la plantilla y el comentario de cierre
  • Múltiples límites
  • Límites sin marcadores de comentario
  • Casos borde

Este tipo de "TDD" es muy útil cuando tienes entradas y salidas esperadas.

Esto no hay que confundirlo con "haz TDD de todo porque lo dice Robert C. Martin" (que va a frenar muchísimo la velocidad de desarrollo de tu equipo). NO deberías usar TDD para la UI ni para partes del código que cambian constantemente.

La solución: resolveSuspenseBoundaries

Una vez que tenía los tests, hice que el LLM escribiera la función resolveSuspenseBoundaries. Usé cheerio para evitar lo frágiles que son las expresiones regulares, aunque usar RegEx aquí habría reducido el tiempo de SSG alrededor de un 40%.

export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
  const originalHtml = html;
  const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
  const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];

  // Collect hidden divs with their content and positions.
  const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
  $('div[hidden][id^="S:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const content = $(el).html() || '';
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset === 'number' && typeof endOffset === 'number') {
      hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
    }
  });

  if (hiddenDivs.size === 0) {
    return {html: originalHtml, didResolveSuspense: false};
  }

  // Find templates (B:0) and replace them with the matching hidden content (S:0),
  // following React’s internal $RV behavior.
  $('template[id^="B:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const divInfo = hiddenDivs.get(boundaryId);
    if (!divInfo) {
      return;
    }
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
      return;
    }

    const templateIndex = startOffset;
    const templateLength = endOffset - startOffset;
    const afterTemplate = originalHtml.substring(templateIndex + templateLength);
    const closingCommentMatch = afterTemplate.match(/<!--\/[
amp;]-->/); const removeEndIndex = closingCommentMatch ? templateIndex + templateLength + closingCommentMatch.index! : templateIndex + templateLength; const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1; const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex); const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex); operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex}); operations.push({index: templateIndex, removeLength: 0, insertText: divContent}); operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex}); operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex}); operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex}); }); operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength)); let resultHtml = originalHtml; for (const operation of operations) { resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength); } return {html: resultHtml, didResolveSuspense: true}; }

Con esto, en lugar de ver una página casi vacía, los motores de búsqueda y los LLM ven una página totalmente renderizada.

Ahora tenemos SSG funcionando bien sin JavaScript. <img alt="SSG sin JavaScript para los blogs de Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

A largo plazo es posible que React cambie el formato de Suspense. Puede que acabe quitando el código que resuelve Suspense cuando tenga una mejor solución para las páginas que se cargan en modo lazy (y que por tanto necesitan límites de Suspense).

Estrategia de hidratación (actualización: esto llevó 3 días + 1 día extra)

La hidratación es complicada. Ya lo sabía. Pero después de un poco de trabajo conseguí que funcionara.

Tiempo total de hidratación: 3 días, más 1 día extra para sustituir el enfoque de "dehydration".

Lo más difícil fue conseguir ese primer "hola mundo" hidratando correctamente. En cuanto logré renderizar un "Hello World" con la barra de navegación, me quedó claro que, sí, esto quizá no me iba a llevar un mes entero.

<img alt="Hello World de Foony hidratando correctamente con la barra de navegación" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Para ese primer ejemplo mínimo de hidratación, tenía un reto especial: quería hidratación, pero también quería un buen SEO para motores de búsqueda y LLM sin que los desarrolladores tuvieran que pensar en los límites de Suspense.

El reto

La hidratación de React es extremadamente literal: si el DOM no se parece a lo que React espera en ese primer render, te muestra un mensaje de error muy majo y casi inútil en la consola, y React tira todo y vuelve a renderizar desde cero. Ni siquiera te saca un diff que te indique qué ha fallado.

En nuestro caso, el SSG lo complicaba todavía más por dos motivos:

  1. Postprocesábamos el HTML para quitar o resolver los artefactos de Suspense del streaming de React 18 (lo cual es genial para bots).
  2. En el cliente no siempre teníamos exactamente los mismos datos disponibles en el tiempo (t = 0) que en el render del servidor (datos de SSG, metadatos del blog, etc.).
  3. Nuestro i18n es "lazy" por defecto, lo que significa que las traducciones pueden faltar en el primer render a menos que registres qué traducciones se usaron en el SSG y las inyectes antes de que React renderice.

Lo que funcionó (enfoque inicial: "dehydration")

Al principio probé algo ingenioso y "mono": usé un patrón comando para registrar los comandos que se usaban para resolver los límites de Suspense en el HTML y devolví los comandos inversos para poder restaurar el HTML al formato que React necesita para hidratar. Mi esperanza era poder enviar muchos menos bytes en el index.html con este sistema de comandos. Pero, como suele pasar con las soluciones demasiado listas, falló porque los navegadores modifican el HTML de formas sutiles, como quitar o añadir un ; o una /, lo que rompía todos los índices de reemplazo. Técnicamente podrías tratar de cubrir estos cambios sutiles del navegador, pero yo no estaba dispuesto a enviar algo tan frágil a producción. En lugar de intentar "revertir" la transformación de los límites de Suspense de vuelta al marcado en streaming de React, hice algo súper sencillo:

Empaquetar el HTML original, sin resolver, dentro de un <script type="text">.

Este enfoque de "dehydration" funcionó, pero me pasé un día extra sustituyéndolo por una solución mejor.

El mejor enfoque: reemplazo de límites de Suspense en la ruta crítica

Después de la implementación inicial, seguía encontrándome con algunos problemas con los límites de Suspense. Ahí me di cuenta de que había una solución más limpia, mejor y más simple. Reemplacé el enfoque de "dehydration" por un reemplazo de límites de Suspense en la ruta crítica, que:

  • Carga la ruta crítica antes de la hidratación: los componentes que se precargaron durante el SSR se identifican y se precargan en el cliente antes de llamar a hydrateRoot
  • Es más sencillo de mantener: no hace falta tocar internals de React ni hacer parseos de AST (el enfoque de "dehydration" tenía que parsear y restaurar HTML)
  • Envía menos bytes: ya no empaquetamos la respuesta original de SSR de React en una etiqueta script
  • Evita un posible "flash": no hace falta deshidratar/rehidratar HTML, así que evitamos un posible parpadeo visual

La implementación registra qué componentes "lazy" se precargaron durante el SSR (a través de SSRLazyComponentTracker), incluye sus rutas de importación en los datos de hidratación y los precarga de forma síncrona antes de hidratar. Los componentes de la ruta crítica se renderizan directamente sin límites de Suspense, igualando exactamente la salida del SSR.

Para todo lo demás, hacemos que el primer render del cliente se comporte como si fuera el SSR/SSG. Eso significa usar las mismas entradas y hacer que estén disponibles de manera síncrona antes de hydrateRoot. Esto lo hacemos a través de lo que llamamos "ssg-data".

En concreto, los ajustes fueron:

  1. Empaquetar las entradas del SSR en un único script de texto

    • Durante el SSG, inyectamos un <script type="text/foony-ssg" id="foony-ssg-data">...</script> justo antes del entrypoint de módulos de Vite.
    • Ese script contiene:
      • html: el HTML resuelto que realmente enviamos en el archivo estático
      • ssgData: el SSGData serializado que usa el wrapper de SSR. Tengo pensado actualizar esto a un Proxy o algo similar para que solo se incluya lo que se acceda.
      • translationData: los blobs de traducciones clave-valor que tocamos durante el SSR
  2. Inyectar esas entradas justo antes de hidratar

    • En main.tsx, de forma síncrona:
      • establecemos #root.innerHTML al HTML resuelto serializado (para que el DOM sea exactamente lo que ve la hidratación)
      • envolvemos la app en SSGDataProvider para que los componentes tengan el mismo SSGData en el primer render
  3. Hacer que i18n sea instantáneo inyectando los valores de traducción

    • Registramos los objetos de traducción que se usaron durante el SSR y los mandamos en el script de SSG.
    • En el cliente, los inyectamos directamente en la caché de LocaleQueryer mediante un método LocaleQueryer.inject(), así que las traducciones están disponibles al instante.

Y con eso, el primer render tiene los mismos datos que tenía el SSR.

El hook useIsSSRMode() ya está implementado en client/src/generators/ssr/isSSRMode.ts:

export function useIsSSRMode(): boolean {
  const [isSSRMode, setIsSSRMode] = React.useState(true);
  
  React.useEffect(() => {
    // After mount (hydration complete), switch to client mode
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

Este hook devuelve true durante el SSR y en el primer render del cliente (hidratación) y luego cambia a false después del montaje. Componentes como UserBanner, Navbar y Dialog ya lo usan para evitar desajustes de hidratación.

  1. Parchear React para tener mejores diffs

Tenía la esperanza de poder usar directamente hydration-overlay. Pero no se mantiene activamente, solo está soportado hasta React 18 y no estaba listo para producción. Así que hice que un LLM clonara el repo para inspirarse y luego creó un overlay de hidratación mínimo en unos minutos. No necesitaba nada sofisticado, solo algo que apareciera durante el desarrollo para ver dónde se rompían las cosas.

Este overlay nuevo es muy básico, así que los diffs no son del todo perfectos. React quita comentarios, añade ; después de atributos de estilo, modifica espacios en blanco y hace unas cuantas cosas pequeñas más que nuestro overlay todavía no contempla. Nuestro overlay también incluye comentarios HTML que React ignora en su hidratación.

<img alt="Nuestro nuevo overlay de hidratación" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

Pero es suficiente para localizar lo que hay que corregir.

<img alt="Diff entre nuestro SSG y el primer render del cliente para la hidratación de React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

En cifras

Para que te hagas una idea de lo que ha supuesto esta implementación:

  • 2 días de trabajo (desde cero hasta tener SSG funcionando). Algo más de 24 horas mientras estaba de vacaciones.
  • 4 días de trabajo para hacer que la hidratación se comporte bien sin carreras asíncronas de traducciones ni useMediaQuery fastidiando cosas.
  • 1 día extra para sustituir el enfoque de "dehydration" por el reemplazo de límites de Suspense en la ruta crítica (más simple, menos bytes, sin posibles parpadeos).
  • ~200 líneas de código para la generación SSG principal (GenerateShellSsgFromSitemap.ts)
  • ~120 líneas de resolución de límites de Suspense (resolveSuspenseBoundaries en renderRoute.tsx) - Nota: esto luego se reemplazó por el enfoque de ruta crítica
  • ~50 líneas de utilidades de SSR (isSSRMode.ts)
  • ~100 líneas de tests (renderRoute.test.ts)
  • ~150 líneas de polyfills para SSR (setupSSREnvironment)
  • Cambios mínimos en los componentes existentes (básicamente añadir comprobaciones con useIsSSRMode())

La solución es ligera y mantenible. No requiere migrar a ningún framework y funciona con nuestra SPA actual de React.

Conclusiones clave

A veces una solución a medida es mejor

No todos los problemas necesitan un framework. Para Foony, una solución de SSG pequeña y a medida era la opción correcta. Es:

  • Ligera: sin dependencias pesadas ni sobrecarga de framework
  • Mantenible: código sencillo que entendemos
  • Flexible: fácil de modificar y ampliar según lo que necesitemos
  • Compatible: funciona con nuestra SPA actual de React sin migrarla

El streaming de SSR de React tiene sus manías

renderToReadableStream de React está muy bien para lidiar con Suspense, pero tiene sus rarezas. Incluso usando await stream.allReady, sigues teniendo límites de Suspense en la salida. No es un bug, está diseñado así para el streaming. Pero para SSG necesitamos HTML completamente resuelto. Da la sensación de que el equipo de React no ha resuelto este caso de uso de una forma limpia.

Mi solución fue postprocesar el HTML y resolver los límites. No es precioso, pero es rápido y lo bastante flexible para lo que yo necesito.

El TDD puede ser útil con LLMs

Transformar HTML es propenso a errores. Un bug pequeño puede romper toda la salida del SSG y arruinar la experiencia del usuario final. Hice que un LLM escribiera tests completos (con mi guía) para asegurar que la transformación funcionara correctamente.

Conclusión

El SSG ya está funcionando en Foony. Las páginas se renderizan completamente para motores de búsqueda y LLM, y la solución es ligera y fácil de mantener. La hidratación de las rutas SSG me llevó más de lo que pensaba (3 días) y me pasé un día extra sustituyendo el enfoque inicial de "dehydration" por el reemplazo de límites de Suspense en la ruta crítica. El enfoque nuevo es más sencillo de mantener, envía menos bytes y evita posibles parpadeos visuales al no tener que deshidratar/rehidratar HTML.

Sigo alucinado de que solo me llevara 2 días implementar una solución de SSG a medida. Pero a veces la solución correcta es la más simple.

El trabajo futuro pasa por terminar de afinar la coincidencia de la hidratación y quizá parchear React para tener mejor depuración. Pero por ahora, Foony ya tiene SSG funcionando. Estaré vigilando Google Search Console y Bing Webmaster Tools durante las próximas semanas para ver qué efecto tiene todo esto en nuestro SEO.

8 Ball Pool online multiplayer billiards icon