Meu portfólio era uma SPA em React com Vite. Para mostrar projetos, isso funciona perfeitamente: quem chega pelo link vê a página montar em milissegundos e navega sem recarregar nada.
Quando decidi transformá-lo em blog, o modelo deixou de servir, e o motivo não é performance.
O problema não é o Google, é o compartilhamento
A primeira reação de todo mundo é "mas o Google executa JavaScript". Executa mesmo. O problema está em outro lugar.
As meta tags de um SPA vivem em um único index.html:
<meta property="og:title" content="Carlos Eduardo Ferreira | Software Engineer" />
<meta property="og:description" content="Software Engineer no Rio de Janeiro..." />
Esse arquivo é o mesmo para todas as rotas. Então, quando alguém compartilha
/blog/meu-artigo no LinkedIn, no X ou no WhatsApp, o crawler lê o HTML servido
(nunca o resultado depois do React rodar) e mostra o preview genérico do
portfólio. Todo artigo fica com o mesmo título e a mesma descrição.
Atualizar document.title em um efeito não resolve:
useEffect(() => {
document.title = post.title; // o crawler social nunca chega aqui
}, [post.title]);
Crawlers de rede social quase nunca executam JavaScript. Para eles, seu blog inteiro é uma página só.
Três caminhos
Avaliei três opções:
| Caminho | SEO | Esforço | O que acontece com o código atual |
|---|---|---|---|
| Continuar client-side | Fraco | Nenhum | Nada muda |
| Migrar para Astro | Ótimo | Alto | Shell e providers reescritos |
| Vite + pré-render no build | Ótimo | Médio | Preservado |
Astro é a escolha canônica para sites de conteúdo, e eu recomendaria para quem começa do zero. No meu caso havia um shell que eu gostava, com sidebar colapsável, background reativo ao mouse e animações, e reescrevê-lo como layout Astro era jogar trabalho fora para resolver um problema de meta tags.
O que eu fiz
Mantive a SPA e adicionei um passo depois do build. O npm run build passou a ter
três etapas:
{
"build": "npm run build:client && npm run build:server && npm run prerender",
"build:client": "vite build",
"build:server": "vite build --ssr",
"prerender": "node scripts/prerender.mjs"
}
A segunda etapa compila a mesma aplicação para rodar em Node. A terceira percorre
cada rota conhecida, renderiza com renderToString, injeta as meta tags daquela
rota e grava um arquivo HTML de verdade:
dist/public/
├── index.html
├── blog/index.html
├── blog/meu-artigo/index.html
├── til/index.html
├── rss.xml
└── sitemap.xml
O resultado: cada artigo tem título, descrição, og:image e JSON-LD próprios no
HTML servido. A SPA continua existindo: ela hidrata por cima do HTML
pré-renderizado e a navegação interna segue instantânea.
O detalhe que quase me pegou
O corpo dos artigos é carregado sob demanda, em um chunk separado. Isso é ótimo para o tamanho do bundle e terrível para hidratação: o servidor renderiza o texto completo, e o cliente, no primeiro render, ainda não tem esse módulo em memória. React reclama da divergência e descarta o HTML.
A solução foi esperar o módulo antes de hidratar:
async function start() {
await preloadBodyForPath(window.location.pathname);
if (container.firstElementChild) hydrateRoot(container, <App />);
else createRoot(container).render(<App />);
}
Como o HTML pré-renderizado já está visível na tela, essa espera é invisível para quem lê.
O que eu levaria para o próximo projeto
Se o site vai ter conteúdo desde o começo, use um framework que gere HTML estático e pare de pensar nisso. Se o conteúdo chega depois, como no meu caso, pré-renderizar rotas conhecidas é um caminho curto e honesto, e você não precisa abandonar nada do que já construiu.