coresmith.dev
← Toate articolele
Blog

SPA-ul tău Vite îi trimite lui Google o pagină goală

O aplicație de o singură pagină se desenează în browser, deci primul lucru pe care îl reține un crawler e un div gol. Uite pasul de build care rezolvă asta fără migrare de framework, și cele două defecte care nu apar niciodată într-un build.

Publicat 6 min de citit

Un build Vite sau Create React App livrează un index.html al cărui corp e un singur element gol:

<div id="root"></div>

Tot ce citește un vizitator e desenat de JavaScript după aceea. Un browser îl rulează. Un crawler, la prima trecere, nu — iar ce reține el e div-ul gol.

Google chiar randează JavaScript, până la urmă. Până la urmă e problema: randarea e o a doua coadă, cu bugetul ei, iar un domeniu nou fără autoritate stă în ea. Bing randează mult mai puțin. Crawlerele din spatele răspunsurilor de AI — GPTBot, ClaudeBot, PerplexityBot — în mare parte nu randează deloc. Pentru ele site-ul tău n-are conținut, și n-o să aibă niciodată.

Verifică-ți site-ul într-o singură comandă

Cere-ți pagina principală așa cum o cere Googlebot, scoate etichetele și numără ce rămâne în corp:

curl -sS -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://example.com/ \
  | python3 -c "
import sys, re
h = sys.stdin.read()
b = re.search(r'<body[^>]*>(.*)</body>', h, re.S).group(1)
b = re.sub(r'<script.*?</script>|<style.*?</style>', '', b, flags=re.S)
print(len(re.sub(r'<[^>]+>', ' ', b).split()))"

Rul-o pe fiecare adresă din sitemap, nu doar pe pagina principală. Pe două site-uri din producție, măsurate înainte să schimbăm ceva, numărul a fost zero pe fiecare dintre ele: 62 de adrese pe site-ul ăsta, 8 pe un site de servicii de acte pe care tot noi îl ținem.

<head>-ul era excelent pe amândouă — titluri, descrieri, Open Graph, JSON-LD, toate scrise cu grijă. Toate descriind o pagină care, pentru crawler, n-avea conținut.

Ce nu e soluția

Sfatul obișnuit e să treci pe Next.js. Asta înseamnă să reconstruiești rutarea, încărcarea datelor și publicarea, pentru o problemă care trăiește în întregime în pasul de build. Dacă aplicația deja funcționează, migrarea e un risc mare pentru o cauză mică.

Ce urmează sunt vreo patruzeci de linii care rulează după vite build: randează fiecare rută o dată, cu un browser fără interfață, peste build-ul pe care urmează să-l publici, și scrie rezultatul pe disc.

Pasul de build

import { createServer } from 'node:http';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';

const execFileP = promisify(execFile);
const DIST = path.resolve('dist');
const ROUTES = ['/', '/preturi', '/despre', '/contact'];

// Un server static peste dist, cu aceeași rezervă de SPA pe care o va folosi nginx.
function serveDist(port) {
  const server = createServer(async (req, res) => {
    const url = new URL(req.url, 'http://x');
    for (const candidate of [
      path.join(DIST, url.pathname),
      path.join(DIST, url.pathname, 'index.html'),
      path.join(DIST, 'index.html'),
    ]) {
      try {
        res.writeHead(200);
        return res.end(await readFile(candidate));
      } catch { /* încearcă următorul */ }
    }
  });
  return new Promise((ok) => server.listen(port, '127.0.0.1', () => ok(server)));
}

const server = await serveDist(4318);
const origin = 'http://127.0.0.1:4318';

for (const route of ROUTES) {
  const { stdout: html } = await execFileP(CHROME, [
    '--headless', '--disable-gpu', '--no-sandbox',
    '--virtual-time-budget=8000',
    '--run-all-compositor-stages-before-draw',
    '--dump-dom', origin + route,
  ], { maxBuffer: 64 * 1024 * 1024 });

  const dir = route === '/' ? DIST : path.join(DIST, route);
  await mkdir(dir, { recursive: true });
  await writeFile(path.join(dir, 'index.html'), html.split(origin).join(SITE));
}

server.close();

Trei detalii țin totul în picioare.

Randezi peste build-ul construit, nu peste serverul de dezvoltare. Serverul de dev servește module netranspilate și alt index.html. Ce randează el nu e ce publici tu.

--virtual-time-budget, nu o pauză fixă. Avansează ceasul paginii cât de repede se termină treaba, deci randarea se încheie când aplicația s-a așezat, nu după o așteptare aleasă la nimereală — iar bugetul e un plafon, nu o întârziere.

Înlocuiește originea de randare în rezultat. Fiecare adresă absolută pe care a emis-o aplicația arată spre http://127.0.0.1:4318. Publici așa, și fiecare pagină declară localhost drept adresa ei canonică — cea mai eficientă cale de a nu fi indexat deloc.

Apoi fă ca o rută goală să oprească build-ul:

const words = html.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
if (words < 80) {
  console.error(`  ${route} a randat ${words} cuvinte`);
  process.exit(1);
}

Defectul de care te aperi aici e tăcut. O rută care nu randează nimic scrie totuși un fișier, iar fișierul ajunge totuși în producție.

Două defecte care nu apar niciodată într-un build

Amândouă au trecut de verificarea de tipuri, de linter și de teste. Amândouă se vedeau doar în rezultat.

Limba schimbată dintr-un efect ajunge prea târziu

Aplicația își citea limba din localStorage și o schimba la montare:

useEffect(() => {
  if (i18n.language !== lang) i18n.changeLanguage(lang);
  document.documentElement.lang = lang;
}, [lang, i18n]);

Într-un browser asta e invizibil. Efectul rulează, React re-randează, vizitatorul vede limba corectă un cadru mai târziu.

Randarea statică face o singură fotografie. Dump-ul a prins marcajul după ce document.documentElement.lang fusese setat și înainte de re-randarea care aducea textele traduse. Fiecare pagină de sub /ru/ a ieșit cu rusa în atributul lang și cu româna în corp — exact contradicția pe care un motor de căutare o citește ca semnal de calitate împotriva ta.

Indiciul a fost în numărul de cuvinte, nu în pagini. Rutele rusești aveau numere identice cu cele românești: 457, 110, 248, 406. Două limbi nu produc același număr de patru ori.

Soluția e să rezolvi limba înainte ca React să randeze:

const initialLang =
  /^\/ru(\/|$)/.test(window.location.pathname) ? 'ru' : 'ro';

i18n.init({ lng: initialLang /* … */ });

Ceea ce scoate și clipirea de limbă greșită pe care o vedea un vizitator real, deci merita făcut oricum.

Un singur <head> pentru toate paginile

index.html are un singur head, și e servit pentru fiecare adresă. Deci <link rel="canonical"> din el spune același lucru peste tot — de obicei pagina principală.

Asta nu e o optimizare lipsă. Un canonical care arată în altă parte e o pagină care îi spune lui Google că e un duplicat și că nu merită indexată separat. Opt pagini, opt declarații că sunt copii ale rădăcinii.

Cu titlurile e aceeași problemă, mai lentă: patru pagini cu același titlu concurează între ele pe aceeași căutare, și nu câștigă niciuna.

Pasul de prerandare e locul potrivit s-o repari, fiindcă are deja fișierul deschis:

const META = {
  '/': ['Acasă — Exemplu', 'Ce face firma, într-o propoziție.'],
  '/preturi': ['Prețuri — Exemplu', 'Cât costă și ce schimbă cifra.'],
};

function rewriteHead(html, route) {
  const [title, description] = META[route];
  const url = SITE + route;
  return html
    .replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`)
    .replace(/<meta name="description" content="[^"]*"/,
             `<meta name="description" content="${description}"`)
    .replace(/<link rel="canonical" href="[^"]*"/,
             `<link rel="canonical" href="${url}"`);
}

Dacă site-ul are mai mult de câteva pagini, generează tabelul ăla din ce ține deja lista de rute, în loc să-l scrii a doua oară.

Cum se servesc fișierele

Cu nginx, configurația obișnuită de SPA e:

location / {
    try_files $uri $uri/ /index.html;
}

Odată ce fiecare rută reală e un fișier pe disc, ultima rezervă nu mai are niciun utilizator legitim. Ajung pe ea doar adresele greșite — și primesc 200 cu pagina principală întreagă. Fiecare greșeală de tastare, fiecare link mort, fiecare scaner care caută /.env.production primește o pagină completă și un status de succes. Google le numește soft 404 și le tratează ca pe o mulțime nemărginită de duplicate.

error_page 404 /404.html;

location / {
    try_files $uri $uri/ =404;
}

O atenționare: $uri/ face nginx să redirecționeze /preturi spre /preturi/ când directorul există. Ori îți scrii adresele canonice cu slash la final, ori emiți fișiere plate preturi.html și adaugi $uri.html în try_files. Ce nu merge e un canonical care arată spre o adresă care redirecționează altundeva.

Ce s-a întâmplat după

Ambele site-uri au trecut de la zero la conținut real: 1201 cuvinte pe pagina principală de aici, 2334 pe cele 8 adrese ale celuilalt. Schimbarea măsurabilă a fost în logurile serverului, numărate pe user-agent:

crawler înainte după
Googlebot 1–5 cereri pe zi 167, apoi 451
GPTBot 1–3 pe zi 208 într-o singură zi
bingbot 0–5 pe zi 24, 17, 11, 32
YandexBot 0 84

Patru crawlere care nu apăruseră niciodată au început să vină într-o săptămână: PerplexityBot, Applebot, DuckDuckBot, Amazonbot. În douăsprezece zile Googlebot a cerut de 1008 ori și a ajuns la 62 de pagini distincte; GPTBot la 87.

ChatGPT-User — agentul care deschide o pagină chiar în timp ce cineva așteaptă un răspuns în ChatGPT — a trecut de la niciodată la 33 de cereri într-o zi.

Ce nu rezolvă asta

Indexarea. La două săptămâni după creșterea aia, site: pe domeniu întorcea în continuare zero, pe două indexuri de căutare diferite.

Nu e o contradicție, și merită spus direct, fiindcă majoritatea articolelor pe tema asta se opresc cu o secțiune mai devreme. Prerandarea scoate motivul tehnic pentru care un crawler nu te poate citi. Nu-i dă unui motor de căutare un motiv să cheltuie spațiu de index pe tine. Motivul ăla se cheamă autoritate — linkuri de pe pagini care sunt ele însele indexate — și e o problemă separată, cu o soluție mai lentă.

Felul corect de a citi cifrele de mai sus e: ușa e acum deschisă. Dacă trece cineva prin ea se hotărăște în altă parte.

Începe cu discuția despre scop. Nu costă nimic și de obicei e un singur apel.