Сборка Vite или Create React App отдаёт index.html, тело которого — один
пустой элемент:
<div id="root"></div>
Всё, что читает посетитель, рисует JavaScript уже после этого. Браузер его выполняет. Краулер на первом проходе — нет, и сохраняет он пустой div.
Google действительно рендерит JavaScript, в конце концов. В конце концов — и есть проблема: рендеринг это вторая очередь со своим бюджетом, и новый домен без авторитета в ней стоит. Bing рендерит гораздо меньше. Краулеры за ответами AI — GPTBot, ClaudeBot, PerplexityBot — в основном не рендерят вовсе. Для них у вашего сайта нет содержимого и не будет.
Проверьте свой сайт одной командой
Запросите главную страницу так, как это делает Googlebot, снимите теги и посчитайте, что осталось в теле:
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()))"
Прогоните это по каждому адресу из sitemap, а не только по главной. На двух боевых сайтах, измеренных до любых изменений, это число было нулём на каждом из них: 62 адреса на этом сайте и 8 на сайте документальных услуг, который мы тоже ведём.
<head> на обоих был отличный — заголовки, описания, Open Graph, JSON-LD, всё
написано тщательно. И всё это описывало страницу, у которой, с точки зрения
краулера, не было содержимого.
Чем решение не является
Обычный совет — перейти на Next.js. Это значит заново собрать маршрутизацию, загрузку данных и деплой ради проблемы, которая целиком живёт в шаге сборки. Если приложение уже работает, миграция — большой риск ради маленькой причины.
Дальше идут примерно сорок строк, которые выполняются после vite build:
отрендерить каждый маршрут один раз headless-браузером, поверх той сборки,
которую вы собираетесь публиковать, и записать результат на диск.
Шаг сборки
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 = ['/', '/pricing', '/about', '/contact'];
// Статический сервер поверх dist, с тем же SPA-фолбэком, что будет у 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 { /* пробуем следующий */ }
}
});
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();
Три детали держат всё это.
Рендерить поверх собранного вывода, а не dev-сервера. Dev-сервер отдаёт
нетранспилированные модули и другой index.html. То, что он рендерит, — не то,
что вы публикуете.
--virtual-time-budget, а не фиксированная пауза. Он двигает часы страницы
настолько быстро, насколько завершается работа, поэтому рендер заканчивается,
когда приложение улеглось, а не после произвольного ожидания — и бюджет здесь
это потолок, а не задержка.
Заменить в выводе адрес рендера. Каждый абсолютный URL, который выдало
приложение, указывает на http://127.0.0.1:4318. Опубликуйте так — и каждая
страница объявит localhost своим каноническим адресом. Более эффективного
способа не попасть в индекс не существует.
Дальше сделайте так, чтобы пустой маршрут ронял сборку:
const words = html.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
if (words < 80) {
console.error(` ${route} отрендерил ${words} слов`);
process.exit(1);
}
Сбой, от которого это защищает, — тихий. Маршрут, который ничего не отрендерил, всё равно пишет файл, и файл всё равно уезжает в продакшен.
Два сбоя, которые никогда не видны в сборке
Оба прошли проверку типов, линтер и тесты. Оба были видны только в результате.
Язык, выставленный из эффекта, приходит слишком поздно
Приложение читало язык из localStorage и переключало его при монтировании:
useEffect(() => {
if (i18n.language !== lang) i18n.changeLanguage(lang);
document.documentElement.lang = lang;
}, [lang, i18n]);
В браузере это незаметно. Эффект отрабатывает, React перерисовывает, посетитель видит нужный язык кадром позже.
Статический рендер делает один снимок. Дамп поймал разметку после того, как
document.documentElement.lang был выставлен, и до перерисовки, которая несла
переведённые строки. Каждая страница под /ru/ уехала с русским в атрибуте
lang и румынским в теле — ровно то противоречие, которое поисковик читает как
сигнал качества против вас.
Улика была в количестве слов, а не в самих страницах. У русских маршрутов были одинаковые числа с румынскими: 457, 110, 248, 406. Два языка не дают одно и то же число четыре раза подряд.
Решение — определить язык до того, как React вообще отрендерит:
const initialLang =
/^\/ru(\/|$)/.test(window.location.pathname) ? 'ru' : 'ro';
i18n.init({ lng: initialLang /* … */ });
Заодно это убирает мигание чужого языка у живого посетителя, так что стоило сделать в любом случае.
Один <head> на все страницы
У index.html один head, и он отдаётся по каждому адресу. Значит,
<link rel="canonical"> в нём говорит одно и то же везде — обычно про главную.
Это не упущенная оптимизация. Canonical, указывающий в другое место, — это страница, которая сообщает Google, что она дубликат и не заслуживает отдельного индексирования. Восемь страниц, восемь заявлений о том, что они копии корня.
С заголовками та же беда, только медленнее: четыре страницы с одним title конкурируют друг с другом за один запрос, и не выигрывает ни одна.
Шаг пререндера — правильное место это починить, потому что файл у него уже открыт:
const META = {
'/': ['Главная — Пример', 'Чем занимается компания, одним предложением.'],
'/pricing': ['Цены — Пример', 'Сколько стоит и что меняет цифру.'],
};
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}"`);
}
Если страниц больше горстки, сгенерируйте эту таблицу из того, что уже хранит список маршрутов, вместо того чтобы писать его второй раз.
Как раздавать файлы
В nginx обычная конфигурация для SPA такая:
location / {
try_files $uri $uri/ /index.html;
}
Как только каждый настоящий маршрут стал файлом на диске, у последнего фолбэка
не осталось ни одного законного пользователя. На него попадают только
неправильные адреса — и получают 200 с полной главной страницей. Каждая
опечатка, каждая мёртвая ссылка, каждый сканер, ищущий /.env.production,
получает целую страницу и успешный статус. Google называет это soft 404 и
считает бесконечным множеством дубликатов.
error_page 404 /404.html;
location / {
try_files $uri $uri/ =404;
}
Одна оговорка: $uri/ заставляет nginx редиректить /pricing на /pricing/,
когда каталог существует. Либо пишите канонические адреса со слешем на конце,
либо отдавайте плоские файлы pricing.html и добавьте $uri.html в
try_files. Что не работает — это canonical, указывающий на адрес, который
редиректит куда-то ещё.
Что было дальше
Оба сайта перешли от нуля к настоящему содержимому: 1201 слово на этой главной, 2334 на восьми адресах второго. Измеримое изменение произошло в логах сервера, если считать по user-agent:
| краулер | до | после |
|---|---|---|
| Googlebot | 1–5 запросов в день | 167, затем 451 |
| GPTBot | 1–3 в день | 208 за один день |
| bingbot | 0–5 в день | 24, 17, 11, 32 |
| YandexBot | 0 | 84 |
Четыре краулера, которые не появлялись никогда, пришли в течение недели: PerplexityBot, Applebot, DuckDuckBot, Amazonbot. За двенадцать дней Googlebot запросил 1008 раз и дошёл до 62 разных страниц; GPTBot — до 87.
ChatGPT-User — агент, который открывает страницу прямо в тот момент, когда
кто-то ждёт ответа в ChatGPT, — перешёл от «никогда» к 33 запросам за день.
Чего это не чинит
Индексацию. Через две недели после того всплеска site: по домену по-прежнему
возвращал ноль, в двух разных поисковых индексах.
Это не противоречие, и сказать об этом прямо стоит, потому что большинство статей на эту тему заканчиваются одним разделом раньше. Пререндер убирает техническую причину, по которой краулер не может вас прочитать. Он не даёт поисковику причину потратить на вас место в индексе. Эта причина называется авторитетом — ссылки со страниц, которые сами проиндексированы, — и это отдельная задача с более медленным решением.
Правильно читать цифры выше так: дверь теперь открыта. Пройдёт ли кто-нибудь в неё, решается в другом месте.