Executando verificação de segurança...
3

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Carregando publicação patrocinada...