Проблема SPA и client-side rendering

Разбираем корень SEO-боли современных JS-сайтов: контент, которого нет в исходном HTML.

Client-Side Rendering (CSR) — подход, при котором сервер отдаёт почти пустой HTML, а весь контент рисует JavaScript в браузере. Классика для SPA на React/Vue без серверного рендеринга.

Что отдаёт сервер в чистом SPA

В типичном CSR-приложении исходный HTML выглядит примерно так:

<body>
  <div id="app"></div>
  <script src="/bundle.js"></script>
</body>

Весь контент — заголовки, текст, ссылки, мета-теги — появляется только после того, как браузер скачает bundle.js, выполнит его и отрисует. Для пользователя с быстрым устройством это незаметно. Для поисковика — проблема.

Почему это бьёт по SEO

  • Первая волна индексации пуста. Бот сразу видит <div id="app"></div> — ни текста, ни заголовков, ни ссылок для дальнейшего обхода.
  • Рендер JS отложен. Вторая волна (исполнение JS) ставится в очередь и может выполниться спустя время — индексация задерживается.
  • Бюджет и сбои. Если bundle тяжёлый, есть ошибка JS или внешний запрос не успел — бот может не получить контент вовсе.
  • Мета-теги на клиенте. title, description, OG, canonical, JSON-LD, проставленные через JS, могут быть не увидены ботами соцсетей (они JS не исполняют вообще).

Как увидеть проблему

Сравните «то, что в браузере» и «то, что в сыром HTML»:

# Сырой ответ сервера — то, что видит бот первой волной
curl -s https://example-spa.com/ | grep -i "<h1"
# Если ничего не нашлось, а в браузере h1 есть -> контент рисует JS

Как работает под капотом: модель двух волн

Смоделируем, что попадает в индекс при разных стратегиях рендеринга. Если контент только в JS, а вторая волна не дошла — индексируется пустота:

def indexed_content(raw_html, js_content, render_js_executed):
    # raw_html  - что в исходнике; js_content - что дорисует JS
    visible = raw_html
    if render_js_executed:           # вторая волна выполнилась
        visible = raw_html + js_content
    return visible.strip() or "(пусто — нечего индексировать)"

csr_raw = ""                          # пустой div
csr_js  = "Заголовок и текст статьи"

print("CSR, рендер НЕ дошёл:", indexed_content(csr_raw, csr_js, False))
print("CSR, рендер дошёл:   ", indexed_content(csr_raw, csr_js, True))
print("SSR (контент в HTML):", indexed_content(csr_js, "", False))

Вывод:

CSR, рендер НЕ дошёл: (пусто — нечего индексировать)
CSR, рендер дошёл:    Заголовок и текст статьи
SSR (контент в HTML): Заголовок и текст статьи

Видно: при SSR контент в индексе есть всегда; при CSR — только если повезло со второй волной. Решение этой проблемы (SSR/SSG) — в следующем уроке.

Частые ошибки

  • «Google же умеет JS». Умеет, но с задержкой, ограничениями по бюджету и не во всех случаях; боты соцсетей и часть поисковиков — нет.
  • Мета-теги и canonical только через JS — ненадёжно для индексации и шеринга.
  • Контент, требующий клика/скролла — бот его не активирует.
  • Ссылки через onclick вместо <a href> — бот не пойдёт по ним, обход обрывается.

Итог

  • В чистом CSR сервер отдаёт пустой HTML, а контент рисует JS — первая волна индексации пуста.
  • Рендер JS отложен и не гарантирован; боты соцсетей JS не исполняют вовсе.
  • Ссылки делайте через <a href>, а ключевые мета-теги — в серверном HTML.
Проверьте себя
1. Что отдаёт сервер в чистом client-side rendered SPA при первом запросе?
AПолностью готовый HTML со всем контентом
BПочти пустой HTML с <div id="app"> и скриптом — контент дорисует JS
CТолько JSON без HTML
DРедирект на отрендеренную версию
2. Почему CSR создаёт проблему для индексации?
AJavaScript вообще не поддерживается поисковиками
BПервая волна видит пустой HTML, а рендер JS отложен и не гарантирован
CCSR автоматически ставит noindex
DCSR ломает robots.txt
3. Почему ссылки лучше делать через <a href>, а не через onclick в SPA?
Aonclick медленнее работает
BБот переходит по href и продолжает обход; onclick-навигацию он не активирует
Chref обязателен по стандарту HTML5
Donclick запрещён в robots.txt