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.