

1/1/1970
Como Implementei SSG em 2 Dias
E aí! Há um ano, eu achava que isso era impossível. Mas acabei de terminar a implementação do Static Site Generation (SSG) para a Foony em 2 dias, e estou bem empolgado com isso. Essa também não é a primeira vez que tento resolver SSG para a Foony. Já avaliei NextJS, Vike, Astro, Gatsby e algumas outras soluções no passado. Cheguei até a fazer uma tentativa frustrada com NextJS, mas esbarrei nas dificuldades da complexidade do SPA da Foony e dos milhares de arquivos. A migração teria sido um pesadelo e levaria meses. Também adicionaria complexidade extra para todos os outros que trabalham no site, porque teriam que aprender NextJS e suas peculiaridades.
Eu queria algo leve e fácil de implementar. Algo que nos permitisse continuar escrevendo código do mesmo jeito que sempre escrevemos, sem ter que pensar em SSG (com a exceção do useMediaQuery, não tem como fugir desse). Abaixo vou explicar por que optei por uma solução sob medida, os desafios específicos que enfrentei (especialmente com os limites de Suspense do React) e como os resolvi.
Por que não Soluções Padrão?
Quando olhei pela primeira vez para adicionar SSG à Foony, naturalmente considerei NextJS (padrão da indústria), Vike e Astro.
NextJS: Migração Demais
O NextJS é poderoso, mas exigiria uma migração massiva do SPA React existente da Foony. Temos milhares de arquivos, lógica de roteamento complexa e muita infraestrutura customizada. Migrar para NextJS significaria:
- Reescrever todo o nosso sistema de roteamento
- Reestruturar como carregamos jogos e componentes
- Meses de trabalho só para voltar à paridade de funcionalidades
- Possíveis quebras para os usuários
- Mudar a forma como lidamos com imagens
- Builds significativamente mais lentos (potencialmente 5 a 30 minutos. Não tenho números concretos para embasar isso, apenas esta discussão de 5 anos atrás no GitHub)
- Toda a equipe aprendendo algo novo (NextJS) e velocidade de desenvolvimento mais lenta para sempre
- Migrar o código toda vez que o NextJS resolver fazer mudanças que quebram compatibilidade.
Cheguei a tentar começar com NextJS, mas rapidamente percebi que o custo da migração era alto demais. A complexidade não valia a pena.
Vike: Complexidade Parecida
O Vike (antigo vite-plugin-ssr) tinha problemas parecidos. Embora seja mais flexível que o NextJS, ainda exigiria uma reestruturação significativa da nossa base de código. A curva de aprendizado e o esforço de migração não justificavam os benefícios.
Astro: Arquitetura Errada
O Astro é ótimo para sites focados em conteúdo, mas a Foony é uma plataforma complexa de jogos multiplayer. Precisamos de atualizações em tempo real, conexões WebSocket e componentes React dinâmicos. A arquitetura do Astro simplesmente não combina com o que estamos construindo.
A Solução: SSG Sob Medida
Animado pela minha abordagem de "SSG falso" que implementei alguns dias atrás depois do i18n, optei por uma solução pequena, leve e sob medida para o SSG da Foony.
Minha abordagem de "SSG falso" envolvia puxar o conteúdo de posts das páginas com posts (rotas
/postse páginas de jogos) e posicioná-los exatamente onde o cliente os renderizaria, especificamente para que mecanismos de busca e LLMs ajudassem a entender a Foony. Também aplicava schema ld+json e algumas pequenas coisas de SEO.
A abordagem é simples:
- Construir em cima do SPA React existente: Sem necessidade de migração, basta adicionar geração de SSG no momento do build.
- Usar
renderToReadableStream: A API de SSR em streaming do React 18 lida com Suspense de forma nativa. - Gerar arquivos HTML estáticos: Pré-renderizar rotas no momento do build e servi-las como arquivos estáticos, usando nosso SitemapGenerator para obter a lista de rotas.
- Mudanças mínimas na base de código existente: A maioria dos componentes funciona como estão.
A implementação principal vive em client/src/generators/GenerateShellSsgFromSitemap.ts. Ele lê um sitemap, renderiza cada rota usando o renderToReadableStream do React e escreve o HTML em arquivos estáticos. Simples, do jeito que eu gosto!
Acabou ficando bem rápido também. Cerca de 2.800 rotas renderizadas em 10 segundos. Que beleza. Isso é significativamente mais rápido que NextJS, Gatsby e Astro. <img alt="Log do console 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 falar sem parar sobre simplicidade. Mesmo que ela não te renda uma promoção em grandes empresas 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 admiro muito nos princípios Zen.
O Problema do Limite de Suspense
Então agora eu tinha SSG e o conteúdo aparecia no HTML... mas minhas páginas estavam em branco! Como?! <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" }} />
Acontece que o renderToReadableStream ainda tem limites de Suspense, mesmo se você usar await stream.allReady. Meu palpite é que isso acontece porque é um "stream", projetado para ser passado aos clientes conforme os bytes são recebidos.
O que o React Produz
Quando você usa renderToReadableStream com Suspense, o React produz HTML assim:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Conteúdo real aqui -->
</div>
...
<script>/*Script que substitui os limites de suspense*/</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 realmente renderizado. O B:0 corresponde ao S:0 pelo número (índice começando em 0).
Sem JavaScript, mecanismos de busca (estou olhando para você, Bing) e LLMs veriam uma página quase em branco com apenas o placeholder do template. Isso anula completamente o propósito do SSG!
Não vi nenhuma maneira limpa de remover esses limites de Suspense, então minha solução foi escrever alguns testes e uma função resolveSuspenseBoundaries para fazer essa substituição. Foi mais rápido do que parsear o HTML e executar o script com algo como JSDOM. E, mais importante, era um requisito para o que eu tinha planejado: um site bonito e legível para mecanismos de busca / LLMs sem JavaScript, mas com suporte a limites de Suspense e hidratação no cliente.
Testando a Transformação
Comecei escrevendo testes para a transformação, pegando alguns exemplos no DOM do que eu tinha (JavaScript desabilitado) e do que eu queria (JavaScript habilitado). Alimentei isso em uma LLM e fiz com que ela gerasse os testes, algo que ela faz muito bem.
Esses testes vivem em client/src/generators/ssr/renderRoute.test.ts e garantem que a transformação funciona corretamente. Os testes cobrem:
- Substituição simples de limite (listagem de blog)
- Limites complexos com conteúdo entre o template e o comentário de fechamento
- Múltiplos limites
- Limites sem marcadores de comentário
- Casos extremos
Esse tipo de "TDD" é realmente bem útil para esse caso de uso, onde você tem entradas e saídas esperadas.
Não confunda isso com "TDD em tudo porque o Robert C. Martin disse" (o que vai diminuir a velocidade de desenvolvimento da sua equipe). Você NÃO deveria usar TDD em UI ou em áreas do seu código que mudam constantemente!
A Solução: resolveSuspenseBoundaries
Agora que os testes estavam no lugar, fiz com que a LLM escrevesse a função resolveSuspenseBoundaries. Optei pelo cheerio para isso, para evitar a fragilidade do RegEx, embora usar RegEx aqui reduzisse o tempo de SSG em cerca de 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}> = [];
// Coleta divs ocultas 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};
}
// Encontra templates (B:0) e os substitui pelo conteúdo oculto 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 vejam uma página totalmente renderizada.
Agora temos 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" }} />
No longo prazo, é possível que o React mude o formato do Suspense. Talvez eu remova o código de resolução do Suspense quando tiver uma solução melhor para as páginas que são carregadas de forma preguiçosa (e que, portanto, exigem limites de Suspense).
Estratégia de Hidratação (Atualização: Isso Levou 3 Dias + 1 Dia Extra)
Hidratação é desafiadora. Eu sabia disso. Mas, depois de um pouco de trabalho, consegui fazê-la funcionar!
Tempo total gasto na hidratação: 3 dias, mais 1 dia extra para substituir a abordagem de desidratação.
A parte mais complicada foi conseguir aquela primeira hidratação mínima funcionando. Quando consegui renderizar um "Hello World" com a navbar, ganhei a confiança de que, sim, isso talvez não levasse um mês inteiro!
<img alt="Hello World da Foony hidratando com sucesso com a navbar" 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 essa primeira hidratação mínima funcional, eu tinha um desafio único: queria hidratação, mas também queria bom SEO para mecanismos de busca e LLMs sem que os desenvolvedores precisassem pensar nos limites de Suspense.
O Desafio
A hidratação do React é extremamente literal: se o DOM não parecer com o que o React espera para aquela primeira renderização, você recebe aquela mensagem de erro bonita e quase inútil no console, e o React joga tudo fora e renderiza do zero. Nem mesmo um diff para te dizer o que deu errado!
No nosso caso, o SSG piorou isso de algumas formas:
- Pós-processamos o HTML para remover/resolver os artefatos de streaming Suspense do React 18 (o que é ótimo para bots).
- O cliente nem sempre tinha exatamente os mesmos dados disponíveis em (t = 0) que a renderização do servidor tinha (dados do SSG, metadados de blog, etc).
- Nosso i18n é "preguiçoso" por padrão, o que significa que traduções podem estar faltando na primeira renderização, a menos que você registre quais traduções foram usadas pelo SSG e as injete antes de o React renderizar.
O que Funcionou (Abordagem Inicial: Desidratação)
No início, tentei algo esperto e bonitinho: usei um padrão de comando para registrar os comandos usados para resolver os limites de Suspense do HTML, e retornei os comandos de transformação reversa para que eu pudesse restaurar o HTML ao que o React precisa para a hidratação.
Minha esperança era que pudesse enviar muito menos bytes no index.html com esse método de comandos. Mas, como na maioria das soluções espertinhas, isso falhou porque os navegadores modificam o HTML de maneiras sutis, como remover ou adicionar um ; ou /, o que desalinhava os índices de substituição.
Tecnicamente, você provavelmente poderia compensar essas mudanças sutis dos navegadores, mas eu não ia colocar em produção algo tão frágil.
Em vez de tentar "reverter" a transformação dos limites de Suspense de volta para a marcação em streaming do React, fiz algo super simples:
Embutir o HTML original, não resolvido, em um <script type="text">.
Essa abordagem de "desidratação" funcionou, mas gastei um dia extra substituindo-a por uma solução melhor.
A Abordagem Melhor: Substituição de Limites de Suspense no Caminho Crítico
Após a implementação inicial, eu ainda tinha alguns problemas com os limites de Suspense. Foi quando percebi que havia uma solução mais limpa, melhor e mais simples. Substituí a abordagem de desidratação pela substituição de limites de Suspense 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 do
hydrateRoot ser chamado
- É mais simples de manter: Sem necessidade de internals do React ou parsing de AST (a abordagem de desidratação precisava parsear e restaurar HTML)
- Envia menos bytes: Não embutimos mais a resposta SSR original do React em uma tag de script
- Evita um possível flash: Sem necessidade de desidratar/re-hidratar HTML, eliminando um possível flash visual
A implementação rastreia quais componentes lazy foram pré-carregados durante o SSR (via SSRLazyComponentTracker), inclui seus caminhos de import nos dados de hidratação e os pré-carrega de forma síncrona antes da hidratação. Componentes do caminho crítico renderizam diretamente sem limites de Suspense, correspondendo exatamente à saída do SSR.
Para todo o resto, fazemos a primeira renderização do cliente agir como SSR/SSG. Isso significa usar as mesmas entradas e tornar essas entradas disponíveis sincronamente antes do hydrateRoot. Isso é feito embutindo via nosso "ssg-data".
Concretamente, os ajustes foram:
Embutir as entradas do SSR em um único script de texto
- Durante o SSG, injetamos um
<script type="text/foony-ssg" id="foony-ssg-data">...</script> logo antes do entrypoint do módulo Vite.
- Esse script contém:
html: o HTML resolvido que efetivamente enviamos no arquivo estático
ssgData: o SSGData serializado usado pelo wrapper SSR. Planejo atualizar isso para um Proxy ou algo assim para que apenas dados acessados sejam incluídos.
translationData: os blobs chave-valor de tradução que tocamos durante o SSR
Injetar essas entradas logo antes da hidratação
- No
main.tsx, sincronamente:
- definimos
#root.innerHTML para o HTML resolvido serializado (para que o DOM seja exatamente o que a hidratação vê)
- envolvemos o app em
SSGDataProvider para que os componentes tenham o mesmo SSGData na primeira renderização
Tornar o i18n instantâneo injetando valores de tradução
- Registramos os objetos de tradução reais acessados durante o SSR e os enviamos no script SSG.
- No cliente, os injetamos diretamente no cache do
LocaleQueryer via um método dedicado LocaleQueryer.inject(), então as traduções ficam disponíveis imediatamente.
E com isso, a primeira renderização tem os mesmos dados que o SSR tinha!
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(() => {
// Após mount (hidratação completa), muda para modo cliente
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Esse hook retorna true durante o SSR e na primeira renderização do cliente (hidratação), depois muda para false após o mount. Componentes como UserBanner, Navbar e Dialog já usam isso para evitar incompatibilidades de hidratação.
- Patch no React para diffs melhores
Eu esperava conseguir usar o hydration-overlay. Mas ele não é mantido ativamente, só tem suporte até o React 18 e não estava pronto para produção. Então fiz uma LLM clonar o repo para inspiração, e em poucos minutos ela criou um overlay de hidratação mínimo. Eu não precisava de nada sofisticado, só algo que aparecesse durante o desenvolvimento para eu descobrir onde as coisas estavam dando errado.
Esse novo overlay é super básico, então os diffs não estão exatamente perfeitos. O React remove comentários, adiciona ; depois de atributos de estilo, modifica espaços em branco e algumas outras pequenas coisas que nosso overlay não considera (ainda). Nosso overlay também inclui comentários HTML que o React ignora para sua hidratação.
<img alt="Nosso novo overlay de hidratação" 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 é bom o suficiente para descobrir o que precisa ser corrigido.
<img alt="diff do nosso SSG vs primeira renderização do cliente para 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 te dar uma ideia do que essa implementação envolveu:
- 2 dias de trabalho (do começo até o SSG funcionando). Isso foi pouco mais de 24 horas durante as férias.
- 4 dias de trabalho para fazer a hidratação se comportar bem sem corridas de tradução assíncronas ou o
useMediaQuery bagunçando as coisas.
- 1 dia extra para substituir a abordagem de desidratação pela substituição de limites de Suspense no caminho crítico (mais simples, menos bytes, sem flash potencial).
- ~200 linhas de código principal de geração de SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 linhas de resolução de limites de Suspense (
resolveSuspenseBoundaries em renderRoute.tsx) - Nota: Isso foi posteriormente substituído pela abordagem de caminho crítico
- ~50 linhas de utilitários SSR (
isSSRMode.ts)
- ~100 linhas de testes (
renderRoute.test.ts)
- ~150 linhas de polyfills para SSR (
setupSSREnvironment)
- Mudanças mínimas nos componentes existentes (principalmente adicionando checagens de
useIsSSRMode())
A solução é leve e fácil de manter. Não exige migração de framework e funciona com nosso SPA React existente.
Principais Aprendizados
Às Vezes uma Solução Sob Medida é Melhor
Nem todo problema precisa de um framework. Para a Foony, uma pequena solução sob medida de SSG foi a escolha certa. Ela é:
- Leve: Sem dependências pesadas ou overhead de framework
- Fácil de manter: Código simples que entendemos
- Flexível: Fácil de modificar e estender conforme necessário
- Compatível: Funciona com nosso SPA React existente sem migração
O SSR em Streaming do React Tem Suas Manhas
O renderToReadableStream do React é legal para lidar com Suspense, mas tem suas manhas. Mesmo com await stream.allReady, você ainda recebe limites de Suspense na saída. Isso não é um bug, é por design para streaming. Mas para SSG, precisamos de HTML totalmente resolvido. Parece uma falha do time do React em não lidar com esse cenário de uma forma limpa.
Minha solução foi pós-processar o HTML e resolver os limites. Não é bonito, mas é rápido e flexível o suficiente para meu caso de uso.
TDD Pode Ser Útil para LLMs
A transformação de HTML é propensa a erros. Um pequeno bug e você pode quebrar toda a saída do SSG e estragar a experiência do usuário final. Fiz uma LLM escrever testes abrangentes (com minha contribuição) para garantir que a transformação funciona corretamente.
Conclusão
O SSG agora está funcionando para a Foony. As páginas são totalmente renderizadas para mecanismos de busca e LLMs, e a solução é fácil de manter e leve. A hidratação para as rotas SSG levou mais tempo do que eu esperava (3 dias), e gastei um dia extra substituindo a abordagem inicial de desidratação pela substituição de limites de Suspense no caminho crítico. A nova abordagem é mais simples de manter, envia menos bytes e evita potenciais flashes visuais de desidratar/re-hidratar HTML.
Ainda estou chocado que só tenha levado 2 dias para implementar uma solução sob medida para SSG. Mas às vezes a solução certa é a mais simples.
Trabalhos futuros incluem completar a correspondência de hidratação e potencialmente fazer um patch no React para melhor depuração. Mas, por enquanto, a Foony tem SSG funcionando. Vou ficar de olho no Google Search Console e nas Bing Webmaster Tools nas próximas semanas para ver que efeito isso tem no nosso SEO.