

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 emocionado por ello. Tampoco es la primera vez que intento resolver el SSG para Foony. He echado un vistazo a NextJS, Vike, Astro, Gatsby y algunas otras soluciones en el pasado. Incluso tuve un comienzo fallido con NextJS, pero me topé con dificultades por la complejidad del SPA de Foony y sus miles de archivos. La migración habría sido una pesadilla y habría llevado meses. También habría añadido complejidad para todos los demás que trabajan en el sitio, porque tendrían que aprender NextJS y sus peculiaridades.
Quería algo ligero y fácil de implementar. Algo que nos permitiera seguir escribiendo código de la misma manera en que lo hemos estado escribiendo, sin tener que pensar en SSG (con la excepción de useMediaQuery, no hay forma real de evitar ese). A continuación, voy a desglosar por qué opté por una solución a medida, los retos concretos con los que me encontré (especialmente con los límites de Suspense de React) y cómo los resolví.
¿Por qué no soluciones estándar?
Cuando empecé a plantearme añadir SSG a Foony, naturalmente consideré NextJS (el estándar de la industria), Vike y Astro.
NextJS: demasiada migración
NextJS es potente, pero habría requerido una migración masiva del SPA de React existente de Foony. Tenemos miles de archivos, lógica de enrutamiento compleja y mucha infraestructura personalizada. Migrar a NextJS habría supuesto:
- Reescribir todo nuestro sistema de enrutamiento
- Reestructurar cómo cargamos los juegos y los componentes
- Meses de trabajo solo para volver a tener la misma funcionalidad
- Posibles cambios disruptivos para los usuarios
- Cambiar la forma en que gestionamos las imágenes
- Tiempos de compilación significativamente más lentos (potencialmente de 5 a 30 minutos. No tengo cifras concretas que lo respalden, salvo este debate de hace 5 años en GitHub)
- Que todo el equipo aprendiera algo nuevo (NextJS) y una velocidad de desarrollo más lenta para siempre
- Migrar el código cada vez que NextJS decide hacer cambios disruptivos.
Incluso intenté un comienzo fallido con NextJS, pero rápidamente me di cuenta de que el coste de la migración era demasiado alto. La complejidad no merecía la pena.
Vike: complejidad similar
Vike (antes vite-plugin-ssr) tenía problemas similares. Aunque es más flexible que NextJS, aun así habría requerido una reestructuración significativa de nuestra base de código. La curva de aprendizaje y el esfuerzo de migración no justificaban los beneficios.
Astro: arquitectura equivocada
Astro es genial para sitios con mucho contenido, pero Foony es una plataforma de juegos multijugador compleja. Necesitamos actualizaciones en tiempo real, conexiones WebSocket y componentes React dinámicos. La arquitectura de Astro simplemente no encaja con lo que estamos construyendo.
La solución: SSG a medida
Animado por mi enfoque de "SSG falso" que implementé hace unos días tras la 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 publicaciones del blog desde las páginas con publicaciones (rutas
/postsy 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 ayudaran a entender Foony. También aplicaba el esquema ld+json y algunos pequeños detalles de SEO.
El enfoque es simple:
- Construir sobre el SPA de React existente: no se necesita migración, solo añadir la generación de SSG en el momento del build.
- Usar
renderToReadableStream: la API de SSR en streaming de React 18 gestiona Suspense de forma nativa. - Generar archivos HTML estáticos: pre-renderizar las rutas en el momento del build y servirlas como archivos estáticos, usando nuestro SitemapGenerator para obtener una lista de rutas.
- Cambios mínimos en la base de código existente: la mayoría de los componentes funcionan tal cual.
La implementación principal vive en client/src/generators/GenerateShellSsgFromSitemap.ts. Lee un sitemap, renderiza cada ruta usando el renderToReadableStream de React y escribe el HTML en archivos estáticos. Simple, ¡tal y como me gusta!
Esto resultó ser bastante rápido también. Unas 2.800 rutas renderizadas en 10 segundos. Genial. Eso es significativamente más rápido que NextJS, Gatsby y Astro. <img alt="Registro de consola del SSG mostrando el tiempo empleado" 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 seguir y seguir hablando de la simplicidad. Aunque no te consiga un ascenso en grandes empresas por "falta de complejidad", el código simple es bonito, mantenible y, en general, mucho mejor para la velocidad de desarrollo. Esto es algo que admiro mucho de los principios Zen.
El problema de los límites de Suspense
Así que ya tenía SSG, y el contenido aparecía en el HTML... ¡pero mis páginas estaban en blanco! ¿¡Cómo!? <img alt="Página en blanco del 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 aún tiene límites de Suspense, incluso si haces await stream.allReady. Mi suposición es que esto se debe a que es un "stream" y está diseñado para ser pasado a los clientes a medida que se reciben los bytes.
Lo que React produce
Cuando usas renderToReadableStream con Suspense, React produce 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 posición donde debería ir el contenido. El <div hidden id="S:0"> contiene el contenido renderizado real. El B:0 coincide con S:0 por número (índice basado 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 con solo el marcador de plantilla. ¡Eso anula por completo el propósito del SSG!
No vi ninguna forma limpia de eliminar estos límites de Suspense, así que mi solución fue escribir algunos tests y una función resolveSuspenseBoundaries para sustituirlos. Esto fue 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 tenía planeado: un sitio bonito y legible para los motores de búsqueda y LLM sin JavaScript, pero con soporte para los límites de Suspense y la hidratación en el cliente.
Probando la transformación
Empecé escribiendo tests para la transformación, capturando algunos ejemplos en el DOM de lo que tenía (con JavaScript desactivado) y de lo que quería (con JavaScript activado). Le pasé estos a un LLM y dejé que se encargara de generar los tests, algo en lo que es bastante bueno.
Estos tests viven en client/src/generators/ssr/renderRoute.test.ts y aseguran que la transformación funciona correctamente. Los tests cubren:
- Reemplazo simple de límites (listado del blog)
- Límites complejos con contenido entre la plantilla y el comentario de cierre
- Múltiples límites
- Límites sin marcadores de comentario
- Casos límite
Este tipo de "TDD" es realmente bastante útil para este caso de uso en el que tienes entradas y salidas esperadas.
Esto no debe confundirse con "TDD para todo porque Robert C. Martin lo dijo" (lo cual ralentizará la velocidad de desarrollo de tu equipo). ¡NO deberías estar usando TDD para la UI o áreas de tu código que cambian constantemente!
La solución: resolveSuspenseBoundaries
Ahora que los tests estaban en su sitio, le hice al LLM escribir la función resolveSuspenseBoundaries. Opté por cheerio para esto para evitar la fragilidad de las RegEx, aunque usar RegEx aquí reduciría el tiempo de SSG en aproximadamente 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};
}
Esto asegura que, en lugar de ver una página casi en blanco, los motores de búsqueda y los LLM vean una página totalmente renderizada.
¡Ahora tenemos el 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 su formato de Suspense. Puede que elimine el código de resolución de Suspense una vez que tenga una solución mejor para las páginas que se cargan de forma diferida (y, por tanto, requieren límites de Suspense).
Estrategia de hidratación (actualización: esto llevó 3 días + 1 día extra)
La hidratación es complicada. Lo sabía. Pero, después de un poco de trabajo, ¡conseguí que funcionara!
Tiempo total empleado en la hidratación: 3 días, más 1 día extra para sustituir el enfoque de deshidratación.
La parte más complicada fue simplemente conseguir esa primera hidratación mínima funcional. Una vez que conseguí renderizar un "Hello World" con la barra de navegación, gané la confianza de que, sí, ¡puede que esto no me lleve un mes entero!
<img alt="Hello World de Foony hidratándose con éxito 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 esa primera hidratación mínima funcional, tenía un reto único: quería hidratación, pero también quería un buen SEO para los motores de búsqueda y los 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 ve como lo que React espera para ese primer renderizado, obtienes este bonito y casi inútil mensaje de error en tu consola, y React tira todo a la basura y vuelve a renderizar desde cero. ¡Ni siquiera un diff para hacerte saber qué salió mal!
En nuestro caso, el SSG empeoraba esto de un par de maneras:
- Post-procesábamos el HTML para eliminar/resolver los artefactos de streaming Suspense de React 18 (lo cual es genial para los bots).
- El cliente no siempre tenía exactamente los mismos datos disponibles en el momento (t = 0) que el render del servidor (datos SSG, metadatos del blog, etc.).
- Nuestra i18n es "lazy" por defecto, lo que significa que las traducciones pueden faltar para el primer render a menos que registres qué traducciones se usaron para el SSG y las inyectes antes de que React renderice.
Lo que funcionó (enfoque inicial: deshidratación)
Al principio, intenté algo ingenioso y simpático: usé un patrón command para registrar los comandos usados para resolver los límites de Suspense del HTML, y devolví los comandos de transformación inversa para poder restaurar el HTML a lo que React necesita para la hidratación.
Mi esperanza era que pudiera enviar muchos menos bytes en index.html con este método de comando. Pero, como con la mayoría de las soluciones ingeniosas, esto falló porque los navegadores modifican el HTML de maneras sutiles, como eliminar o añadir un ; o /, lo cual desbarataba los índices de reemplazo.
Técnicamente, probablemente podrías tener en cuenta estos cambios sutiles del navegador, pero no iba a publicar algo tan frágil.
En lugar de intentar "revertir" la transformación de los límites de Suspense de vuelta al markup de streaming de React, hice algo súper simple:
Empaquetar el HTML original sin resolver en un <script type="text">.
Este enfoque de "deshidratación" funcionó, pero pasé un día extra sustituyéndolo por una solución mejor.
El mejor enfoque: reemplazo de límites Suspense en la ruta crítica
Tras la implementación inicial, seguía teniendo algunos problemas con los límites de Suspense. Fue entonces cuando me di cuenta de que había una solución más limpia, mejor y más simple. Sustituí el enfoque de deshidratación por el reemplazo de límites 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 que se llame a
hydrateRoot
- Es más simple de mantener: no se requieren elementos internos de React ni parseo de AST (el enfoque de deshidratación necesitaba parsear y restaurar el HTML)
- Envía menos bytes: ya no empaquetamos la respuesta SSR original de React en una etiqueta script
- Previene un posible parpadeo: no hay necesidad de deshidratar/rehidratar el HTML, eliminando un posible parpadeo visual
La implementación rastrea qué componentes lazy fueron precargados durante el SSR (a través de SSRLazyComponentTracker), incluye sus rutas de import en los datos de hidratación, y los precarga de forma síncrona antes de la hidratación. Los componentes de la ruta crítica se renderizan directamente sin límites de Suspense, coincidiendo exactamente con la salida del SSR.
Para todo lo demás, hacemos que el primer render del cliente actúe como SSR/SSG. Eso significa usar las mismas entradas, y hacer que esas entradas estén disponibles de forma síncrona antes de hydrateRoot. Esto se hace empaquetándolas a través de nuestro "ssg-data".
Concretamente, los ajustes fueron:
Empaquetar las entradas de 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 del módulo de Vite.
- Ese script contiene:
html: el HTML resuelto que realmente enviamos en el archivo estático
ssgData: el SSGData serializado usado por el wrapper de SSR. Tengo previsto actualizarlo a un Proxy o algo así para que solo se incluyan los datos a los que se accede.
translationData: los blobs clave-valor de traducción que tocamos durante el SSR
Inyectar esas entradas justo antes de la hidratación
- En
main.tsx, de forma síncrona:
- establecemos
#root.innerHTML al HTML resuelto serializado (para que el DOM sea exactamente lo que la hidratación ve)
- envolvemos la app en
SSGDataProvider para que los componentes tengan los mismos SSGData en el primer render
Hacer i18n instantáneo inyectando los valores de traducción
- Registramos los objetos de traducción reales a los que se accedió durante el SSR y los enviamos en el script SSG.
- En el cliente, los inyectamos directamente en la caché de
LocaleQueryer a través de un método dedicado LocaleQueryer.inject(), para que las traducciones estén disponibles inmediatamente.
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 (la 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.
- Parchear React para mejores diffs
Esperaba poder simplemente usar hydration-overlay. Pero no se mantiene activamente, solo es compatible hasta React 18 y no estaba listo para producción. Así que hice que un LLM clonara el repositorio 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 poder averiguar dónde iban mal las cosas.
Este nuevo overlay es súper básico, así que los diffs no son del todo perfectos. React elimina los comentarios, añade ; después de los atributos style, modifica los espacios en blanco y algunas otras cosas pequeñas que nuestro overlay no tiene en cuenta (todavía). Nuestro overlay también incluye comentarios HTML que React ignora para 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 lo suficientemente bueno como para averiguar qué hay que arreglar.
<img alt="diff de nuestro SSG vs 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 implicó esta implementación:
- 2 días de trabajo (desde el inicio hasta tener el SSG funcionando). Esto fue poco más de 24 horas estando de vacaciones.
- 4 días de trabajo para que la hidratación se comportara bien sin carreras de traducción asíncronas o que
useMediaQuery lo estropeara todo.
- 1 día extra para sustituir el enfoque de deshidratación por el reemplazo de límites Suspense en la ruta crítica (más simple, menos bytes, sin posible parpadeo).
- ~200 líneas de código principal de generación SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 líneas de resolución de límites Suspense (
resolveSuspenseBoundaries en renderRoute.tsx). Nota: esto fue sustituido más tarde por el enfoque de la ruta crítica
- ~50 líneas de utilidades 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 (principalmente añadir comprobaciones
useIsSSRMode())
La solución es ligera y mantenible. No requiere una migración de framework, y funciona con nuestro SPA de React existente.
Conclusiones clave
A veces una solución a medida es mejor
No todos los problemas necesitan un framework. Para Foony, una solución SSG pequeña y a medida fue la elección correcta. Es:
- Ligera: sin dependencias pesadas ni sobrecarga de framework
- Mantenible: código simple que entendemos
- Flexible: fácil de modificar y extender según sea necesario
- Compatible: funciona con nuestro SPA de React existente sin migración
El SSR en streaming de React tiene sus peculiaridades
El renderToReadableStream de React es bonito para tratar con Suspense, pero tiene peculiaridades. Incluso con await stream.allReady, sigues obteniendo límites de Suspense en la salida. Esto no es un bug, es por diseño para el streaming. Pero para SSG, necesitamos HTML totalmente resuelto. Parece un fallo del equipo de React no manejar este escenario de una manera limpia.
Mi solución fue post-procesar el HTML y resolver los límites. No es bonito, pero es lo suficientemente rápido y flexible para mi caso de uso.
TDD puede ser útil para los LLM
La transformación de HTML es propensa a errores. Un pequeño bug y podrías romper toda la salida SSG y arruinar la experiencia del usuario final. Hice que un LLM escribiera tests exhaustivos (con mi aporte) para asegurar que la transformación funciona correctamente.
Conclusión
El SSG ya está funcionando para Foony. Las páginas se renderizan completamente para los motores de búsqueda y los LLM, y la solución es mantenible y ligera. La hidratación para las rutas SSG llevó más tiempo del que esperaba (3 días), y pasé un día extra sustituyendo el enfoque inicial de deshidratación por el reemplazo de límites Suspense en la ruta crítica. El nuevo enfoque es más simple de mantener, envía menos bytes y previene posibles parpadeos visuales por deshidratar/rehidratar el HTML.
Sigo asombrado de que solo me llevara 2 días implementar una solución a medida para SSG. Pero a veces la solución correcta es la más simple.
El trabajo futuro incluye completar la coincidencia de hidratación y, potencialmente, parchear React para una mejor depuración. Pero por ahora, Foony tiene SSG funcionando. Estaré atento a Google Search Console y Bing Webmaster Tools durante las próximas semanas para ver qué efecto tiene esto en nuestro SEO.