Pré-renderização em SPA Vite/React sem migrar para Next.js
Aplicação React 18 + Vite, TypeScript, com cerca de 1.500 rotas (a grande maioria geradas dinamicamente por slugs de estado/cidade/loja). É um marketplace público — casasdebaterias.com.br — onde tráfego orgânico de busca local é critério de sobrevivência.
O problema clássico: SPA puro renderiza no cliente, e crawler social (LinkedIn, WhatsApp, Twitter) não executa JS. Googlebot até executa, mas atrasado, e o LCP do bot tava em 4.8s. As páginas começaram a sair do índice ou a indexar com vazio.
Tinha dois caminhos:
Migrar pra Next/Remix → 2-3 sprints reescrevendo providers, autenticação Supabase, contextos, hooks.
Pré-renderizar no build → uma tarde de trabalho.
Fui no segundo. Funcionou. Vou mostrar exatamente como, com o que doeu, e o que não cobre.
A ideia em 3 linhas
No build, subo um servidor local servindo o dist/ do Vite e abro cada rota com Puppeteer.
Capturo o HTML hidratado e injeto numa div "espelho" no index.html daquela rota.
Essa div é escondida por CSS pra quem tem JS; crawlers e enxergam.
Quando o React monta, ele hidrata sobre o #root (que segue vazio) e a div espelho some por já estar display: none. Sem flash, sem dupla renderização visível.
O script
// scripts/prerender.mjs
import puppeteer from 'puppeteer';
import express from 'express';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createClient } from '@supabase/supabase-js';
const PORT = 4173;
const DIST = resolve('dist');
// 1) Lista de rotas: estáticas + dinâmicas do banco
async function getRoutes() {
const staticRoutes = [
'/', '/como-funciona', '/sobre', '/planos',
'/contato', '/blog', '/cobertura',
];
const sb = createClient(
process.env.VITE_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE,
);
const { data: stores } = await sb
.from('stores')
.select('slug, city_slug, state_slug')
.eq('active', true);
const dynamic = stores.map(
s => `/${s.state_slug}/${s.city_slug}/${s.slug}`
);
return [...staticRoutes, ...dynamic];
}
// 2) Servidor local pro Puppeteer (não bate em produção)
function startStaticServer() {
const app = express();
app.use(express.static(DIST));
app.get('*', async (_req, res) => {
const html = await readFile(resolve(DIST, 'index.html'), 'utf8');
res.send(html);
});
return new Promise(r => {
const server = app.listen(PORT, () => r(server));
});
}
// 3) Renderizar uma rota
async function renderRoute(browser, route) {
const page = await browser.newPage();
await page.goto(`http://localhost:${PORT}${route}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
// Espera a flag que o app expõe quando terminou de carregar dados
await page
.waitForFunction('window.__PRERENDER_READY__ === true', { timeout: 15000 })
.catch(() => {});
const payload = await page.evaluate(() => {
const root = document.getElementById('root');
const title = document.title;
const description =
document.querySelector('meta[name="description"]')?.content || '';
const ogTags = [...document.querySelectorAll('meta[property^="og:"]')]
.map(m => m.outerHTML)
.join('\n');
const jsonLd = [...document.querySelectorAll('script[type="application/ld+json"]')]
.map(s => s.outerHTML)
.join('\n');
return {
content: root?.innerHTML || '',
title,
description,
ogTags,
jsonLd,
};
});
await page.close();
return payload;
}
// 4) Injeção no template e gravação do index.html da rota
async function inject(template, route, payload) {
const html = template
.replace(/<title>.*<\/title>/, `<title>${payload.title}</title>`)
.replace(
'<!--SSR_META-->',
`<meta name="description" content="${payload.description}">\n` +
`${payload.ogTags}\n${payload.jsonLd}`,
)
.replace(
'<!--SSR_CONTENT-->',
`<noscript>${payload.content}</noscript>\n` +
`<div id="prerender" aria-hidden="true">${payload.content}</div>`,
);
const outDir = resolve(DIST, route === '/' ? '' : route.slice(1));
await mkdir(outDir, { recursive: true });
await writeFile(resolve(outDir, 'index.html'), html);
}
async function main() {
const server = await startStaticServer();
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const template = await readFile(resolve(DIST, 'index.html'), 'utf8');
const routes = await getRoutes();
console.log(`Pré-renderizando ${routes.length} rotas...`);
// Batches pra não estourar memória do CI
const BATCH = 5;
for (let i = 0; i < routes.length; i += BATCH) {
const batch = routes.slice(i, i + BATCH);
await Promise.all(
batch.map(async route => {
try {
const payload = await renderRoute(browser, route);
await inject(template, route, payload);
console.log(`✓ ${route}`);
} catch (e) {
console.error(`✗ ${route}: ${e.message}`);
}
}),
);
}
await browser.close();
server.close();
}
main();
O index.html (template)
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Casas de Baterias</title>
<!--SSR_META-->
<link rel="stylesheet" href="/assets/index.css" />
<style>
/* O truque do espelho:
- existe no DOM pro crawler indexar
- some pra quem tem JS, antes do React paintar */
#prerender { display: none; }
noscript #prerender { display: block; }
</style>
</head>
<body>
<!--SSR_CONTENT-->
<div id="root"></div>
<script type="module" src="/assets/index.js"></script>
</body>
</html>
A flag __PRERENDER_READY__
Sem ela, o Puppeteer captura HTML antes dos useEffect resolverem e o snapshot vem sem dados. No app:
// src/lib/prerender-ready.ts
import { useEffect } from 'react';
export function usePrerenderReady(isReady: boolean) {
useEffect(() => {
if (!isReady || typeof window === 'undefined') return;
// Dois rAF garantem que o paint terminou.
// Sem isso, eventualmente o snapshot pega DOM ainda sem layout estável.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
(window as any).__PRERENDER_READY__ = true;
});
});
}, [isReady]);
}
Uso em cada página de conteúdo:
function StorePage({ slug }: { slug: string }) {
const { data, isLoading } = useStore(slug);
usePrerenderReady(!isLoading && !!data);
if (isLoading) return <Skeleton />;
return <StoreView store={data} />;
}
O duplo requestAnimationFrame é o mesmo truque que uso pra evitar flash de hidratação em outras partes do app — força esperar até o frame em que tudo já foi pintado, não só montado.
Hidratação sem flash
#root começa vazio no HTML servido. Quando o bundle baixa, o React monta nele. O #prerender continua com display: none.
Crawler que não executa JS vê tudo via (Google, Facebook social, LinkedIn) ou via #prerender direto no source (Bing tende a ignorar em algumas situações, por isso a redundância). Texto dentro de display: none ainda é indexável; o que o Google penaliza é hiding malicioso, não estrutural com aria-hidden.
Googlebot, que executa JS, pega o HTML final do #root depois da hidratação e também não enxerga o #prerender. Não dá conflito porque os dois nunca aparecem ao mesmo tempo.
Core Web Vitals: antes x depois
Mesma URL, mesma semana, PSI mobile com 5 runs:
MétricaAntes (CSR puro)Depois (prerender)LCP4.8s1.6sFCP2.9s0.8sTBT480ms290msCLS0.080.02
LCP melhorou porque o "maior elemento" (h1 + card hero) chega no primeiro byte. TBT caiu porque o usuário enxerga conteúdo enquanto o JS ainda baixa, então a janela de "página travada" sumiu.
No Search Console, a curva de páginas indexadas saiu de ~40% pra 92% em 6 semanas. As que ficaram de fora eram rotas com noindex mesmo (painel, checkout, manutenção).
Trade-offs (os que doeram)
- Build ficou pesado. 1.500 rotas × ~1.5s/rota com paralelismo de 5 = ~7 minutos no CI. Inviável rodar localmente toda hora. Rodo só no pipeline antes do deploy, com cache de Puppeteer.
- Dados congelam no HTML estático. Loja muda o nome → HTML pré-renderizado fica desatualizado até o próximo build. Pra mim é OK: rebuild diário em cron resolve. Pra quem precisa de dados em tempo real, é deal-breaker — tem que ir de SSR/ISR de verdade.
- Não cobre rotas privadas. Login, painel, checkout: tudo CSR puro e noindex. Não tem por que pré-renderizar o que não vai pro índice.
- Dupla renderização visível em rotas lentas. Se a hidratação demora (ex.: dispositivo low-end), dá pra ver o conteúdo estático ficar "parado" antes do JS assumir interatividade. Mitigado com skeleton matching, mas em páginas com muita interação ainda é perceptível.
- Puppeteer no CI é frágil. Memory limits do GitHub Actions estouram fácil. Tive que ajustar batches, --no-sandbox, e timeouts mais agressivos. Em projeto sério, isso vira flaky test se não cuidar.
Quando NÃO vale a pena
Conteúdo majoritariamente atrás de login → SSR puro, sem prerender.
Rotas que mudam várias vezes ao dia → ISR de verdade (Next, Astro).
Equipe já confortável com Next → migra e ganha DX de bandeja.
Mais de 10k rotas → o build vira proibitivo sem distribuir entre workers, e aí o custo de manter isso fica maior que migrar.
Se a app é pública, SEO importa, e a stack já tá em Vite/React funcionando: vale o investimento de uma tarde pra evitar uma semana de migração.
Onde tá rodando
Esse setup tá em produção em produção há ~8 meses. Quem quiser ver na prática, abre uma página de cobertura por cidade e dá Ctrl+U — o conteúdo principal aparece no source mesmo com JS desabilitado no navegador.
Curioso pra ouvir de quem rodou estratégia parecida com mais de 10k rotas. Imagino que aí o build vira inviável sem distribuir entre workers paralelos, mas nunca passei desse volume pra saber na prática. Comenta aí o que funcionou (ou não) no seu cenário.