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

Но возник простой вопрос: «А что из этого вообще видит поисковик?»
И ответ неприятный. С точки зрения SEO вся страница с поиском выглядела просто как /palettes, либо URL с GET-параметрами, которые поисковики могут индексировать, но без каноникализации, что приводит к дублям и размытию релевантности. Одна страница, без категорий, без цветов, без фильтров. Хотя по факту внутри было десятки разных состояний, например:
/palettes/category/halloween
/palettes/color/red
/palettes/style/pastel
/palettes/count/5
В итоге классическая ситуация: пользователю удобно, поисковику — ничего не понятно. Нужно было выкручиваться. В итоге я пришел к использованию History API, каноникализации URL и динамическому SEO через React Helmet. И дальше расскажу, как это всё собрать в работающую систем.
Если посмотреть на типичное React-приложение глазами браузера, то есть один HTML-файл и один корневой элемент
<div id="root"></div>
И дальше уже внутри этого div живёт всё приложение — роутинг, страницы, поиск, фильтры, данные. Всё, что видит пользователь, отрисовывается JavaScript’ом, но есть нюанс.
Метатеги страницы — <title>, <meta description>, canonical — задаются в исходном HTML, обычно в файлике index.html. И если их не менять, то для поисковика страница остаётся одной и той же, независимо от того, что происходит внутри React.
В результате получается ситуация: пользователь кликает фильтры, меняется контент, отображаются совсем другие палитры, но при этом URL остаётся тем же, <title> не меняется, <meta description> не меняется, и для поиска это все одна и та же страница. И в итоге пользователь работает с полноценным поиском, а поисковик видит один единственный URL, в моем случае - «/palettes».
Очевидное решение напрашивается: само собой, если пользователь меняет состояние страницы — должен меняться URL.
В браузере для этого уже давно есть инструмент — History API. И это именно то, что нужно для SPA. History API позволяет менять адресную строку без перезагрузки страницы.
Как применить?
Вместо того чтобы хранить всё только в React state, нужно сделать архитектуру таким образом, чтобы URL стал источником правды для поиска. Т.е. теперь, фильтры отражаются в URL и состояние страницы полностью описывается URL.
Для универсальности и возможности переиспользования вынесем сразу всю логику в слой абстракции
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 уже участвует в жизни приложения, его нужно правильно синхронизировать со стейтом, и здесь есть два направления: читать состояние из URL и записывать состояние в URL. И можно легко попасть на бесконечные циклы.
Первое, что нужно сделать — это восстанавливать состояние из 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]);
Теперь в обратную сторону. Когда пользователь добавляет фильтр, или удаляет токен, или вводит текст, нужно обновить 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 начинает отражать состояние поиска, довольно быстро всплывает следующая проблема — дубликаты страниц.
На первый взгляд всё работает отлично: фильтры попадают в 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, а все "альтернативные" варианты автоматически приводятся к единому виду.
После того как 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