惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Project Zero
Project Zero
Martin Fowler
Martin Fowler
人人都是产品经理
人人都是产品经理
C
Check Point Blog
S
SegmentFault 最新的问题
腾讯CDC
IT之家
IT之家
Jina AI
Jina AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
B
Blog RSS Feed
博客园 - Franky
V
Visual Studio Blog
The Register - Security
The Register - Security
GbyAI
GbyAI
博客园 - 【当耐特】
Hugging Face - Blog
Hugging Face - Blog
U
Unit 42
Google DeepMind News
Google DeepMind News
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
云风的 BLOG
云风的 BLOG
博客园 - 三生石上(FineUI控件)
Schneier on Security
Schneier on Security
N
News and Events Feed by Topic
T
Troy Hunt's Blog
小众软件
小众软件
Scott Helme
Scott Helme
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
TaoSecurity Blog
TaoSecurity Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
C
CERT Recently Published Vulnerability Notes
aimingoo的专栏
aimingoo的专栏
T
The Exploit Database - CXSecurity.com
Know Your Adversary
Know Your Adversary
G
GRAHAM CLULEY
T
Tenable Blog
P
Palo Alto Networks Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
S
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
H
Help Net Security
WordPress大学
WordPress大学
The Cloudflare Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
MongoDB | Blog
MongoDB | Blog
Forbes - Security
Forbes - Security
H
Hackread – Cybersecurity News, Data Breaches, AI and More
W
WeLiveSecurity
I
InfoQ
P
Privacy International News Feed

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я сделал SEO-дружелюбный поиск в React через History API и React Helmet
4Nun4ku (АО · 2026-04-28 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение8 мин

Охват и читатели495

Кейс

Я фронтенд-разработчик, и в одном из своих пет-проектов на 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