

1/1/1970
Como implementei SSG em 2 dias
E aí! Um ano atrás eu achava que isso era impossível. Mas acabei de terminar de implementar Static Site Generation (SSG) para Foony em 2 dias e estou bem empolgado com isso. Não é a primeira vez que tento resolver SSG para Foony, não. Já dei uma olhada em NextJS, Vike, Astro, Gatsby e algumas outras soluções no passado. Cheguei até a começar algo com NextJS, mas trombei com a complexidade da SPA da Foony e com milhares de arquivos. A migração teria sido um pesadelo e levaria meses. Também teria acrescentado mais complexidade para todo mundo que trabalha no site, porque todo mundo teria que aprender NextJS e as manhas dele.
Eu queria algo leve e fácil de implementar. Algo que deixasse a gente continuar escrevendo código do mesmo jeito que já escreve hoje, sem ter que ficar pensando em SSG (com exceção de useMediaQuery, aí não tem muito como fugir). Aqui embaixo eu conto por que escolhi uma solução sob medida, os desafios específicos que apareceram (principalmente com as Suspense boundaries do React) e como resolvi tudo.
Por que não usar as soluções padrão?
Quando comecei a pensar em adicionar SSG à Foony, naturalmente considerei NextJS (padrão da indústria), Vike e Astro.
NextJS: migração demais
NextJS é poderoso, mas exigiria uma migração enorme da SPA em React que a Foony já tem. A gente tem milhares de arquivos, lógica de rotas bem complexa e muita infraestrutura própria. Migrar para NextJS significaria:
- Reescrever todo o nosso sistema de rotas
- Reestruturar como carregamos jogos e componentes
- Meses de trabalho só para voltar a ter as mesmas funcionalidades
- Risco de quebrar coisas para os usuários
- Mudar a forma como lidamos com imagens
- Tempos de build bem mais lentos (potencialmente 5–30 minutos. Não tenho números concretos para provar isso além desta discussão de 5 anos atrás no GitHub)
- A equipe inteira teria que aprender algo novo (NextJS), e a velocidade de desenvolvimento ia cair para sempre
- Ter que migrar o código toda vez que o NextJS decidisse fazer mudanças incompatíveis
Cheguei até a começar algo com NextJS, mas logo percebi que o custo da migração era alto demais. A complexidade não valia a pena.
Vike: complexidade parecida
Vike (antigo vite-plugin-ssr) tinha problemas parecidos. Apesar de ser mais flexível que o NextJS, ainda exigiria uma reestruturação grande do nosso código. A curva de aprendizado e o esforço de migração não compensavam os benefícios.
Astro: arquitetura errada
Astro é ótimo para sites cheios de conteúdo, mas Foony é uma plataforma complexa de jogos multiplayer. A gente precisa de atualizações em tempo real, conexões WebSocket e componentes React dinâmicos. A arquitetura do Astro simplesmente não encaixa no que estamos construindo.
A solução: SSG sob medida
Animado pela abordagem de "SSG falso" que implementei alguns dias atrás depois de i18n, acabei escolhendo uma solução pequena, leve e sob medida para o SSG da Foony.
Minha abordagem de "SSG falso" envolvia puxar o conteúdo dos posts de blog das páginas que têm blog (rotas
/postse páginas de jogo) e posicionar esse conteúdo exatamente onde o cliente renderizaria, especificamente para mecanismos de busca e LLMs entenderem melhor a Foony. Também aplicava schema ld+json e algumas coisinhas de SEO.
A abordagem é simples:
- Construir em cima da SPA em React que já existe: nada de migração, só adicionar geração SSG no momento do build.
- Usar
renderToReadableStream: a API de SSR com streaming do React 18 lida com Suspense nativamente. - Gerar arquivos HTML estáticos: pré-renderizar rotas no build e servir como arquivos estáticos, usando nosso SitemapGenerator para pegar a lista de rotas.
- Mudanças mínimas no código existente: a maioria dos componentes funciona como já estava.
A implementação principal fica em client/src/generators/GenerateShellSsgFromSitemap.ts. Ela lê um sitemap, renderiza cada rota usando renderToReadableStream do React e grava o HTML em arquivos estáticos. Simples, do jeitinho que eu gosto!
E acabou ficando bem rápido também. Aproximadamente 2.800 rotas renderizadas em 10 segundos. Nada mal. Isso é significativamente mais rápido que NextJS, Gatsby e Astro. <img alt="Log do console do SSG mostrando o tempo gasto" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Eu poderia ficar horas falando de simplicidade. Mesmo que isso não te renda promoção em empresa grande por "falta de complexidade", código simples é bonito, fácil de manter e, em geral, muito melhor para a velocidade de desenvolvimento. Isso é algo que eu admiro bastante nos princípios Zen.
O problema das Suspense boundaries
Agora eu já tinha SSG, e o conteúdo aparecia no HTML... mas minhas páginas estavam em branco! Como assim?! <img alt="Página em branco do 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" }} />
Descobri que o renderToReadableStream ainda mantém as Suspense boundaries, mesmo se você usa await stream.allReady. Minha aposta é que isso acontece porque é um "stream", pensado para ser enviado ao cliente à medida que os bytes vão chegando.
O que o React gera
Quando você usa renderToReadableStream com Suspense, o React gera um HTML mais ou menos assim:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Conteúdo real aqui -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
O <template id="B:0"> é um placeholder onde o conteúdo deveria ir. O <div hidden id="S:0"> contém o conteúdo renderizado de verdade. O B:0 bate com o S:0 pelo número (índice começando em 0).
Sem JavaScript, mecanismos de busca (estou olhando pra você, Bing) e LLMs veriam uma página quase em branco, só com o placeholder do template. Isso acaba com o propósito inteiro do SSG!
Eu não vi nenhuma forma limpa de remover essas Suspense boundaries, então minha solução foi escrever alguns testes e uma função resolveSuspenseBoundaries para trocar essas estruturas. Isso foi mais rápido do que parsear o HTML e executar o script com algo tipo JSDOM. E, mais importante, era um requisito para o que eu queria: um site legível para mecanismos de busca / LLMs sem JavaScript, mas ainda com suporte a Suspense boundaries e hidratação no cliente.
Testando a transformação
Comecei escrevendo testes para a transformação pegando exemplos do DOM com o que eu tinha (JavaScript desativado) e com o que eu queria (JavaScript ativado). Joguei isso em um LLM e deixei ele cuidar da geração dos testes, algo em que ele é bem bom.
Esses testes ficam em client/src/generators/ssr/renderRoute.test.ts e garantem que a transformação funciona direitinho. Os testes cobrem:
- Substituição simples de boundary (listagem do blog)
- Boundaries complexas com conteúdo entre o template e o comentário de fechamento
- Múltiplas boundaries
- Boundaries sem marcadores de comentário
- Casos de borda
Esse tipo de "TDD" é bem útil nesse cenário em que você tem entradas e saídas esperadas.
Isso não tem nada a ver com "fazer TDD em tudo porque o Robert C. Martin falou" (o que só vai deixar o desenvolvimento da sua equipe mais lento). Você NÃO deveria usar TDD para UI ou áreas do código que mudam o tempo todo!
A solução: resolveSuspenseBoundaries
Com os testes prontos, pedi para o LLM escrever a função resolveSuspenseBoundaries. Escolhi usar cheerio aqui para evitar a fragilidade de RegEx, mesmo que usar RegEx cortasse o tempo do SSG em uns 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}> = [];
// Coletar divs escondidas com seu conteúdo e posições.
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};
}
// Encontrar templates (B:0) e substituí-los pelo conteúdo escondido correspondente (S:0),
// seguindo o comportamento interno $RV do React.
$('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};
}
Isso garante que, em vez de ver uma página quase em branco, mecanismos de busca e LLMs veem uma página totalmente renderizada.
Agora a gente tem SSG funcionando bem sem JavaScript!
<img alt="SSG sem JavaScript para os blogs da 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 longo prazo, é bem possível que o React mude o formato das Suspense. Talvez eu remova esse código de resolução de Suspense quando tiver uma solução melhor para as páginas que são lazy-loaded (e que por isso precisam de Suspense boundaries).
Estratégia de hidratação (Atualização: isso levou 3 dias + 1 dia extra)
Hidratação é complicada. Eu sabia disso. Mas, depois de um pouco de trabalho, consegui fazer funcionar!
Tempo total da hidratação: 3 dias, mais 1 dia extra para trocar a abordagem de desidratação.
A parte mais chata foi conseguir aquele primeiro "Hello World" hidratando, mesmo que bem mínimo. Depois que consegui renderizar um "Hello World" com a navbar, ganhei confiança de que, sim, isso talvez não fosse levar um mês inteiro!
<img alt="Hello World da Foony hidratando com sucesso com a barra de navegação" 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 esse primeiro hydrate mínimo funcionando, eu tinha um desafio diferente: eu queria hidratação, mas também queria um SEO bom para mecanismos de busca e LLMs, sem que os devs precisassem se preocupar com Suspense boundaries.
O desafio
A hidratação do React é extremamente literal: se o DOM não estiver exatamente do jeito que o React espera para o primeiro render, você ganha aquela mensagem linda e quase inútil no console, e o React joga tudo fora e re-renderiza do zero. Nem um diff para você saber o que deu errado!
No nosso caso, o SSG piorava isso de alguns jeitos:
- A gente pós-processava o HTML para remover / resolver os artefatos de Suspense do streaming do React 18 (o que é ótimo para bots).
- O cliente nem sempre tinha exatamente os mesmos dados disponíveis no tempo (t = 0) que o servidor tinha durante o render (dados de SSG, metadados de blog etc.).
- Nosso i18n é "lazy" por padrão, o que significa que traduções podem faltar no primeiro render, a menos que você registre quais traduções foram usadas no SSG e injete tudo antes do React renderizar.
O que funcionou (abordagem inicial: desidratação)
No começo, tentei algo esperto e bonitinho: usei um command pattern para registrar os comandos usados para resolver as Suspense boundaries do HTML e, depois, retornava os comandos inversos para restaurar o HTML para o que o React precisa na hidratação.
Minha esperança era conseguir enviar bem menos bytes no index.html usando esse esquema de comandos. Mas, como acontece com quase toda solução "esperta", isso falhou porque os navegadores modificam o HTML de formas sutis, tipo remover ou adicionar um ; ou /, o que estragava os índices de substituição.
Tecnicamente você até poderia dar um jeito de lidar com essas mudanças sutis do navegador, mas eu não ia colocar em produção algo tão frágil.
Em vez de tentar "reverter" a transformação das Suspense boundaries de volta para o markup de streaming do React, fiz algo super simples:
Enviar o HTML original, não resolvido, dentro de um <script type="text">.
Essa abordagem de "desidratação" funcionou, mas acabei gastando um dia extra trocando por uma solução melhor.
A solução melhor: substituição de Suspense boundaries no caminho crítico
Depois da implementação inicial, eu ainda estava esbarrando em alguns problemas com Suspense boundaries. Foi aí que percebi que existia uma forma mais limpa, melhor e mais simples. Troquei a abordagem de desidratação por substituição de Suspense boundaries no caminho crítico, que:
- Carrega o caminho crítico antes da hidratação: componentes que foram pré-carregados durante o SSR são identificados e pré-carregados no cliente antes de chamar
hydrateRoot
- É mais simples de manter: não precisa mexer em partes internas do React nem fazer parsing de AST (a abordagem de desidratação precisava parsear e restaurar HTML)
- Envia menos bytes: não empacotamos mais a resposta original do SSR do React em uma tag de script
- Evita um possível flash: não precisamos desidratar / reidratar HTML, o que elimina um possível flash visual
A implementação rastreia quais componentes lazy foram pré-carregados durante o SSR (via SSRLazyComponentTracker), inclui seus paths de importação nos dados de hidratação e os pré-carrega de forma síncrona antes da hidratação. Os componentes do caminho crítico rendem direto sem Suspense boundaries, batendo exatamente com a saída do SSR.
Para todo o resto, fazemos com que o primeiro render no cliente se comporte como SSR/SSG. Isso significa usar as mesmas entradas e disponibilizar essas entradas sincronamente antes de chamar hydrateRoot. Isso é feito empacotando tudo via o nosso "ssg-data".
Na prática, os ajustes foram:
Empacotar as entradas de SSR em um único script de texto
- Durante o SSG, a gente injeta um
<script type="text/foony-ssg" id="foony-ssg-data">...</script> logo antes do entrypoint de módulo do Vite.
- Esse script contém:
html: o HTML já resolvido que a gente realmente entrega como arquivo estático
ssgData: o SSGData serializado usado pelo wrapper de SSR. Pretendo atualizar isso para um Proxy ou algo assim para só incluir os dados que forem acessados.
translationData: os blobs de chave-valor de tradução que foram usados durante o SSR
Injetar essas entradas logo antes da hidratação
- No
main.tsx, a gente, de forma síncrona:
- define
#root.innerHTML para o HTML resolvido serializado (assim o DOM fica exatamente como o React espera na hidratação)
- envolve o app em
SSGDataProvider, para os componentes terem o mesmo SSGData já no primeiro render
Deixar o i18n instantâneo injetando os valores de tradução
- Registramos os objetos de tradução realmente acessados durante o SSR e enviamos isso dentro do script de SSG.
- No cliente, injetamos direto no cache do
LocaleQueryer por meio de um método dedicado LocaleQueryer.inject(), para que as traduções estejam disponíveis imediatamente.
Com isso, o primeiro render tem os mesmos dados que o SSR teve!
O hook useIsSSRMode() já está implementado em client/src/generators/ssr/isSSRMode.ts:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// Depois do mount (hidratação concluída), muda para modo cliente
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Esse hook retorna true durante o SSR e no primeiro render do cliente (hidratação), e depois muda para false após o mount. Componentes como UserBanner, Navbar e Dialog já usam isso para evitar problemas de hidratação.
- Fazer um patch no React para ter diffs melhores
Eu queria muito só usar o hydration-overlay. Mas ele não é mantido ativamente, só suporta até React 18 e não estava pronto para produção. Então pedi para um LLM clonar o repositório como inspiração e, a partir disso, ele criou um overlay de hidratação mínimo em poucos minutos. Eu não precisava de nada sofisticado, só de algo que aparecesse no ambiente de desenvolvimento para eu conseguir ver onde as coisas estavam dando errado.
Esse novo overlay é bem básico, então os diffs não ficam tão perfeitos assim. O React remove comentários, adiciona ; depois de atributos de style, mexe em espaços em branco e faz mais algumas coisinhas que o nosso overlay ainda não leva em conta. Nosso overlay também inclui comentários HTML que o React ignora na hidratação.
<img alt="Nosso novo hydration overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Mas já é suficiente para descobrir o que precisa ser corrigido.
<img alt="Diff entre nosso SSG e o primeiro render do cliente para a hidratação do 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" }} />
Em números
Para você ter uma ideia do que essa implementação envolveu:
- 2 dias de trabalho (do zero até o SSG funcionar). Isso foi um pouco mais de 24 horas enquanto eu estava de férias.
- 4 dias de trabalho para deixar a hidratação se comportando direitinho, sem corridas assíncronas de tradução ou
useMediaQuery bagunçando as coisas.
- 1 dia extra para trocar a abordagem de desidratação pela substituição de Suspense boundaries no caminho crítico (mais simples, menos bytes, sem flash potencial).
- ~200 linhas de código central de geração de SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 linhas de resolução de Suspense boundaries (
resolveSuspenseBoundaries em renderRoute.tsx) - Observação: isso foi substituído depois pela abordagem do caminho crítico
- ~50 linhas de utilitários de SSR (
isSSRMode.ts)
- ~100 linhas de testes (
renderRoute.test.ts)
- ~150 linhas de polyfills para SSR (
setupSSREnvironment)
- Mudanças mínimas em componentes existentes (basicamente adicionando checagens de
useIsSSRMode())
A solução é leve e fácil de manter. Não exige migração de framework e funciona com a nossa SPA em React do jeito que ela já é.
Principais aprendizados
Às vezes uma solução sob medida é melhor
Nem todo problema precisa de um framework. Para a Foony, uma solução pequena e sob medida de SSG foi a escolha certa. Ela é:
- Leve: nada de dependências pesadas nem overhead de framework
- Fácil de manter: código simples que a gente entende
- Flexível: fácil de mudar e estender conforme necessário
- Compatível: funciona com a nossa SPA em React sem precisar migrar
O SSR com streaming do React tem suas esquisitices
O renderToReadableStream do React é ótimo para lidar com Suspense, mas tem suas manias. Mesmo com await stream.allReady, você ainda ganha Suspense boundaries na saída. Isso não é um bug, é assim de propósito por causa do streaming. Mas para SSG, a gente precisa de HTML totalmente resolvido. Parece uma falha da equipe do React não ter uma forma limpa de lidar com esse cenário.
Minha solução foi pós-processar o HTML e resolver as boundaries. Não é bonito, mas é rápido e flexível o suficiente para o meu caso de uso.
TDD pode ser útil com LLMs
Transformação de HTML é algo propenso a erro. Um bugzinho e você quebra a saída inteira do SSG e a experiência do usuário final. Usei um LLM para escrever testes bem completos (com a minha orientação) para garantir que a transformação funcionasse corretamente.
Conclusão
SSG agora está funcionando na Foony. As páginas são totalmente renderizadas para mecanismos de busca e LLMs, e a solução é leve e fácil de manter. A hidratação das rotas SSG levou mais tempo do que eu esperava (3 dias), e ainda gastei um dia extra trocando a abordagem inicial de desidratação pela substituição de Suspense boundaries no caminho crítico. A nova abordagem é mais simples de manter, envia menos bytes e evita flashes visuais por conta de desidratar / reidratar HTML.
Ainda fico meio chocado que levou só 2 dias para implementar uma solução sob medida de SSG. Mas às vezes a solução certa é a mais simples mesmo.
Trabalho futuro inclui terminar de ajustar todo o matching de hidratação e, talvez, fazer alguns patches no React para melhorar a depuração. Mas, por enquanto, a Foony já tem SSG funcionando. Vou ficar de olho no Google Search Console e no Bing Webmaster Tools nas próximas semanas para ver o efeito disso no nosso SEO.