Как мы сделали SSR для React SPA без Next.js
Как мы сделали SSR для React SPA без Next.js
У BookStrata нет Next.js. Вообще. Клиент — чистая React SPA на Vite, сервер — Fastify. Изначально так сложилось исторически, и когда встал вопрос SEO, мигрировать на Next.js было нецелесообразно.
Но поисковые боты должны видеть контент. Расскажу, как мы решили эту задачу без переписывания архитектуры.
Почему не Next.js
Три причины:
1. Готовая кодовая база. SPA на 50+ страниц, сложный drag-and-drop редактор, асинхронные маршруты. Переезд на Next.js — это недели работы и гарантированные регрессии.
2. SSR не нужен везде. Редактор тир-листов, дашборд, админка — этим страницам не нужен SSR, они за авторизацией. Индексироваться должны только публичные страницы: лендинг, тир-листы, коллекции, рейтинги.
3. Prerender дешевле. Если страницы статические или меняются раз в сутки, дешевле сгенерировать HTML одинжды, чем гонять SSR при каждом запросе.
Выбрали третий вариант.
Архитектура
Схема работы:
Пользователь / Бот → Nginx
├── Если User-Agent содержит Googlebot / YandexBot / FacebookBot
│ → отдаём предварительно сгенерированный HTML
└── Иначе
→ отдаём SPA (index.html), React рендерит на клиенте
Nginx определяет бота по User-Agent и решает, отдавать статический HTML или SPA.
Как генерируется HTML
После деплоя запускается скрипт scripts/prerender.mjs. Он:
1. Берёт список маршрутов для пререндера
2. Для каждого маршрута запускает headless Chrome (puppeteer)
3. Ждёт, пока React отрендерит страницу и загрузятся данные
4. Сохраняет готовый HTML в dist/
Ключевые моменты:
- Ожидание данных. Скрипт ждёт, пока на странице появится хотя бы один элемент с классом
.rendered-content. - Таймаут 15 секунд. Если страница не загрузилась — пропускаем, бот увидит обычную SPA (лучше, чем ничего).
- Инкрементальность. Пререндерятся только новые или изменённые страницы.
Пример из продакшена:
dist/
├── index.html ← лендинг (SPA fallback)
├── rankings/index.html ← рейтинг книг
├── what-to-read/index.html ← что почитать
└── collections/
├── horror-books/index.html
├── science-fiction/index.html
└── ...
Экспорт маршрутов коллекций
Отдельная задача — узнать, какие страницы нужно пререндерить. Маршруты коллекций живут в базе данных, их slug-и меняются.
Для этого перед prerender запускается скрипт scripts/export-collection-routes.ts, который делает SQL-запрос к PostgreSQL и сохраняет список slug-ов в JSON-файл. Prerender уже читает этот файл.
npx tsx scripts/export-collection-routes.ts
# → сохраняет ["horror-books", "science-fiction", ...] в routes.json
Nginx: как разруливаем ботов
Главный трюк — в конфигурации nginx. Мы используем карту User-Agent:
map $http_user_agent $is_bot {
default 0;
~*googlebot 1;
~*yandexbot 1;
~*facebot 1;
~*twitterbot 1;
~*slackbot 1;
}
server {
listen 443 ssl;
location / {
# Если запрашивает бот — ищем статический HTML
if ($is_bot) {
rewrite ^/(.*)$ /bot/$1 last;
}
# Иначе — обычная SPA
try_files $uri $uri/ /index.html;
}
location /bot/ {
internal;
alias /usr/share/nginx/html/;
try_files $uri ${uri}/index.html /index.html =404;
}
}
Бот стучится на /rankings → nginx проверяет User-Agent → перенаправляет на /bot/rankings/index.html → если файл есть, отдаёт его; если нет — 404, который падает на обычный index.html.
Цифры
До внедрения:
- Яндекс индексировал 0 страниц (SPA)
- Google — около 5 страниц (только лендинг)
После:
- Яндекс: ~120 страниц в индексе
- Google: ~200 страниц
- Время загрузки для ботов: 200–400 мс (статический HTML vs 3–5 секунд для SPA)
Что не идеально
- Динамические страницы. Если на странице часто меняется контент (например, список популярных тир-листов), prerender устаревает до следующего деплоя. Решение — фрагменты, которые подгружаются через API даже в пререндеренном HTML. Но это усложнение, которое мы пока не внедрили.
- Размер. Каждая страница — отдельный HTML-файл. Для 200 страниц это ~15 МБ диска. На современном VPS это копейки.
- Инвалидация. Нет механизма перегенерации конкретной страницы без полного деплоя. Для нашего объёма трафика это не проблема, но если проект вырастет — придётся добавить.
Выводы
Prerender через headless Chrome — рабочий вариант для проектов, где SSR нужен «по краям». Он не требует миграции на другой фреймворк, не усложняет архитектуру и даёт 95% того же эффекта для SEO.
Если у вас готовая SPA и встал вопрос индексации — не спешите переписывать всё на Next.js. Попробуйте сначала prerender. Возможно, как в нашем случае, этого окажется достаточно.