javascript

Как я сделал SEO-дружелюбный поиск в React через History API и React Helmet

  • среда, 29 апреля 2026 г. в 00:00:06
https://habr.com/ru/companies/gnivc/articles/1026836/

Я фронтенд-разработчик, и в одном из своих пет-проектов на React — сервисе с цветовыми палитрами — мне нужно было сделать функционал фильтров, токенов поиска и поиска по названию, где пользователь мог бы выбрать цвет, задать стиль палитры, отфильтровать по количеству цветов и вводить текстовый запрос.
И все получилось: интерфейс удобный, всё меняется мгновенно, без перезагрузок, как и ожидается от современного приложения.

Но возник простой вопрос: «А что из этого вообще видит поисковик?»

И ответ неприятный. С точки зрения SEO вся страница с поиском выглядела просто как /palettes, либо URL с GET-параметрами, которые поисковики могут индексировать, но без каноникализации, что приводит к дублям и размытию релевантности. Одна страница, без категорий, без цветов, без фильтров. Хотя по факту внутри было десятки разных состояний, например:

  • /palettes/category/halloween

  • /palettes/color/red

  • /palettes/style/pastel

  • /palettes/count/5

В итоге классическая ситуация: пользователю удобно, поисковику — ничего не понятно. Нужно было выкручиваться. В итоге я пришел к использованию History API, каноникализации URL и динамическому SEO через React Helmet. И дальше расскажу, как это всё собрать в работающую систем.

Проблема SPA и SEO

Если посмотреть на типичное React-приложение глазами браузера, то есть один HTML-файл и один корневой элемент

<div id="root"></div>

И дальше уже внутри этого div живёт всё приложение — роутинг, страницы, поиск, фильтры, данные. Всё, что видит пользователь, отрисовывается JavaScript’ом, но есть нюанс.

Метатеги страницы — <title>, <meta description>, canonical — задаются в исходном HTML, обычно в файлике index.html. И если их не менять, то для поисковика страница остаётся одной и той же, независимо от того, что происходит внутри React.

В результате получается ситуация: пользователь кликает фильтры, меняется контент, отображаются совсем другие палитры, но при этом URL остаётся тем же, <title> не меняется, <meta description> не меняется, и для поиска это все одна и та же страница. И в итоге пользователь работает с полноценным поиском, а поисковик видит один единственный URL, в моем случае - «/palettes».

History API

Очевидное решение напрашивается: само собой, если пользователь меняет состояние страницы — должен меняться URL.

В браузере для этого уже давно есть инструмент — History API. И это именно то, что нужно для SPA. History API позволяет менять адресную строку без перезагрузки страницы.

Как применить?

Вместо того чтобы хранить всё только в React state, нужно сделать архитектуру таким образом, чтобы URL стал источником правды для поиска. Т.е. теперь, фильтры отражаются в URL и состояние страницы полностью описывается URL.

Обёртка над History API

Для универсальности и возможности переиспользования вынесем сразу всю логику в слой абстракции

const { pathname, search, hash, replacePath, pushPath } = useRouteNavigation();

А в коде уже будем использовать

replacePath('/palettes/category/halloween');
pushPath('/palettes/color/red');

Под капотом это обычный History API, но код чище и нет дублирования, прямо по фэншую.

Как это работает в поиске

Когда пользователь меняет фильтры или вводит текст, собирается новый URL:

const target = buildCatalogTarget({ tokens, search });

и дальше обновляется адрес с помощью

replacePath(`${target.pathname}${target.search}`);

Например, /palettes/style/light или /palettes/style/pastel. А, если несколько категорий, то /all?styles=pastel%2Cmedium

Здесь стоит обратить внимание на то, что фильтры могут меняться часто и, если каждый раз делать pushState, история браузера быстро превращается в помойку, поэтому для поиска лучше использовать replacePath(...) — это обновляет URL, но не засоряет историю.

А вот если происходит переход между страницами — тогда уже можно использовать pushPath.

Этим мувом я добился того, что у каждой комбинации фильтров появился свой URL, страницу можно открыть напрямую, можно поделиться ссылкой, начинает нормально работать кнопка “назад”

И самое главное: теперь состояние приложения существует не только в React, но и в URL. А значит становится доступным и для пользователя, и для поисковика.

Синхронизация URL/state

В момент, когда URL уже участвует в жизни приложения, его нужно правильно синхронизировать со стейтом, и здесь есть два направления: читать состояние из URL и записывать состояние в URL. И можно легко попасть на бесконечные циклы.

URL → state

Первое, что нужно сделать — это восстанавливать состояние из URL. Например, когда пользователь открыл ссылку, нажал “назад”, или перешёл по сохранённому URL.

В этом случае приложение должно понять, какие фильтры были выбраны.

У меня это выглядит так. Просто берём location.search, парсим параметры и восстанавливаем tokens и search. Но я сделал не просто парсинг query-параметров, а дополнительно учитываю “принудительные” фильтры из URL (например, category/style/color, которые приходят из pathname), дополняю tokens, если этих фильтров нет в query, и сразу привожу URL к каноническому виду. 

То есть при открытии страницы происходит не просто восстановление состояния, а ещё и его нормализация.

useEffect(() => {
  const parsed = parsePaletteSearchStateFromLocation({
    search: currentSearch,
    labelFor,
  });

  let nextTokens = [...parsed.tokens];
  const nextSearch = parsed.searchText;

  // добавляем "принудительные" фильтры из pathname
  if (forcedCategorySlug) {
    nextTokens.push({
      label: labelFor('category', forcedCategorySlug),
      value: `categories:${forcedCategorySlug}`,
    });
  }

  syncingFromUrlRef.current = true;

  setSearch(nextSearch);
  setTokens(nextTokens);

  // приводим URL к каноническому виду
  const target = buildCatalogTarget({ tokens: nextTokens, search: nextSearch });
  replacePath(`${target.pathname}${target.search}`);

}, [currentSearch]);

State → URL

Теперь в обратную сторону. Когда пользователь добавляет фильтр, или удаляет токен, или вводит текст, нужно обновить URL. Мы собираем URL из текущего state, вызываем replacePath (под капотом history.replaceState) и получаем, что адресная строка всегда отражает текущее состояние поиска.

useEffect(() => {
  if (syncingFromUrlRef.current) return;

  const target = buildCatalogTarget({ tokens, search });

  replacePath(`${target.pathname}${target.search}`);
}, [tokens, search]);

Защита от бесконечного цикла

Из-за преобразований из стейт в URL можно легко попасть в бесконечный цикл и, соответственно, нужно придумать защиту от этого. В целом можно сделать через дополнительную переменную в виде стейта, но можно и сделать через

const syncingFromUrlRef = useRef(false);

И дальше, когда читаем из URL

syncingFromUrlRef.current = true;
setSearch(nextSearch);
setTokens(nextTokens)

а когда записываем в URL, то делаем вот так:

if (syncingFromUrlRef.current) return;

И получается, что, если обновление пришло из URL — мы не трогаем URL, а если обновление пришло из UI — обновляем URL.

Каноникализация URL (ключевой SEO-блок)

Когда URL начинает отражать состояние поиска, довольно быстро всплывает следующая проблема — дубликаты страниц.

На первый взгляд всё работает отлично: фильтры попадают в URL, страницы можно открывать, делиться ими, всё индексируется. Но, если посмотреть внимательнее, один и тот же результат можно получить разными способами.

Например:

/palettes/all?categories=halloween
/palettes/category/halloween

С точки зрения пользователя — это одно и то же, а точки зрения поисковика — это две разные страницы с одинаковым контентом. И это уже проблема, которая размывает релевантность, появляются дубли и, соответственно, ухудшается ранжирование.

Поисковики не любят, когда один и тот же контент доступен по разным URL. Для них это выглядит как “непонятно, какую страницу считать основной”. В итоге ни одна из них не получает нормального веса. Здесь нам нужно ввести правило, что у каждой страницы должен быть один канонический URL.

Как это реализовано

Логика довольно простая: если выбран один фильтр — используем человеко-читаемый путь, если фильтров несколько — используем query-параметры.

Например, если ?categories=Halloween, то делаем palettes/category/Halloween.А если фильтров больше, то оставляем /palettes/all?styles=pastel,light.

И даже при такой логике всё равно полезно явно указать поисковику правильный адрес страницы. Для этого добавляем canonical

<link rel="canonical" href={canonical} />

Где canonical — это уже нормализованный URL (через toCanonicalUrl).
В итоге получаем уже вполне SEO-дружелюбную структуру.

Но одного canonical-тега недостаточно. Дополнительно лучше делать активную нормализацию URL прямо на клиенте. То есть при заходе на страницу проверять, соответствует ли текущий URL каноническому, и, если нет — сразу приводить его к правильному виду через replacePath.

if (pathname !== target.pathname || currentQuery !== target.rawQuery) {
  replacePath(`${target.pathname}${target.search}`);
}

Таким образом пользователь и поисковик всегда работают только с каноническим URL, а все "альтернативные" варианты автоматически приводятся к единому виду.

React Helmet и SEO

После того как URL стал единственным источником правды и мы разобрались с каноникализацией, нужно разобраться еще с метаданными. Проблема, помимо того, что у нас есть один HTML и один <div id="root"> ,еще и в том, что и метатеги стандартные из файла index.html, и получается, что на любой странице всегда один и тот же тег <title>  и тег <meta description>. А для поисковика это снова одна и та же страница. Здесь на помощь приходит React Helmet. Он позволяет управлять SEO данными прямо из React, но сам по себе SEO не улучшает — он лишь даёт возможность динамически менять head.

Я вынес всю SEO-логику в отдельный компонент, внутри которого обычный Helmet вида:

<Helmet>
  <title>{title}</title>
  <meta name="description" content={description} />
  <link rel="canonical" href={canonical} />
</Helmet>

Собственно, и все: заполняем нашими данными эти теги и готово. Также не забываем поддержку соцсетей

<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />

Это нужно, чтобы при шаринге ссылки подтягивался правильный заголовок, отображалось описание и была красивая превьюшка. Ещё стоит заморочиться и сделать разметку страницы, это тоже помогает в продвижении в поиске, потому что поисковик лучше понимает структуру страницы.

Выглядит это вот так:

<script type="application/ld+json">
  {safeJsonLd({
    "@context": "https://schema.org",
    "@type": "WebPage",
    url: canonical,
    name: title,
    description
  })}
</script>

Есть ещё один момент, про который не стоит забывать — это индексация поисковых страниц. Если пользователь вводит текстовый запрос с get параметром (например ?q=sunset или другим), таких страниц может быть бесконечно много, и индексировать их смысла нет — это мусорные страницы с точки зрения SEO.

Поэтому я дополнительно помечаю такие страницы как noindex. Это говорит поисковику: страницу не индексировать, но при этом переходить по ссылкам с неё (noindex,follow). Так в индекс попадают только осмысленные страницы (категории, цвета, стили), а не бесконечные варианты поисковых запросов.

Результаты

После всех этих действий поведение приложения поменялось довольно сильно — причём не только “под капотом”, но и снаружи.

Во-первых, у каждой категории фильтров появился свой человекочитаемый URL, а у комбинации фильтров ссылка с get параметрами. Теперь это не просто состояние внутри React, а полноценная страница, на которую можно перейти напрямую, сохранить в закладки или отправить кому-то.

Во-вторых, лучше работает навигация. Кнопки “назад” и “вперёд” больше не ломают интерфейс, а ведут себя так, как ожидает пользователь. История браузера перестала быть случайным набором состояний.

Кроме этого, сохраняется состояние поиска при переходе в карточку палитры. Это позволяет при возврате назад попадать ровно в тот же список с теми же фильтрами, а не начинать поиск заново.

С точки зрения пользователя это выглядит естественно, а с точки зрения архитектуры — это ещё одно следствие того, что URL полностью описывает состояние приложения.

В-третьих, исчез рассинхрон между UI и адресной строкой. Раньше можно было получить ситуацию: фильтры одни, а URL — другой. Теперь такого просто не бывает — потому что URL и есть источник правды.

И, наконец, самое важное — появился фундамент для SEO. У страниц есть уникальные адреса, у них есть корректные <title> и <meta description>, есть canonical, и нет дублей.

Теперь поисковик начинает видеть не “одну страницу”, а структуру сайта. Если кому интересно, потыкать можно здесь https://colorage.ru/palettes/all