Как мы сделали SSR для React SPA без Next.js

2026-07-175 мин

Как мы сделали 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//index.html

Ключевые моменты:

  • Ожидание данных. Скрипт ждёт, пока на странице появится хотя бы один элемент с классом .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. Возможно, как в нашем случае, этого окажется достаточно.

Используем куки и рекомендательные технологии. Это чтобы сайт работал лучше. Оставаясь с нами, вы соглашаетесь на использование файлов куки.