Современные клиентские роутеры (React Router v7, TanStack Router) управляют переходами между представлениями с помощью браузерного History API. Когда пользователь кликает по ссылке, роутер перехватывает нативное событие браузера (e.preventDefault()), обновляет адресную строку через history.pushState() и перерисовывает дерево компонентов.
С точки зрения реального пользователя такой переход происходит мгновенно. Однако для поисковых краулеров эта механика порождает две тяжелые архитектурные проблемы:
pushState. Они извлекают абсолютные пути из атрибутов href тегов <a href="...">, обнаруженных в статическом HTML, и отправляют к каждому URL независимый HTTP-запрос. Если ссылки сформированы некорректно (например, <button onClick="..."> или <a href="#/catalog">), краулер никогда не обнаружит целевые страницы.index.html с кодом HTTP 200 OK:
try_files $uri $uri/ /index.html;/products/deleted-slug-999), сервер возвращает код 200 OK. Затем загружается бандл, запускается роутер, понимает, что такого товара нет, и рендерит компонент <NotFound />. Поисковый робот видит код 200 OK и индексирует страницу с ошибкой как валидный документ.Soft 404 наносит колоссальный ущерб: Googlebot тратит суточный лимит краулинга на обход тысяч несуществующих ссылок, а страницы с дублирующимся текстом «Товар не найден» снижают авторитетность домена.
Для корректной поисковой индексации SPA требуется синхронизация трех уровней: сетевого транспортного уровня (HTTP Status), адресной строки браузера (Canonical URL) и состояния роутера в памяти.
+------------------------------------------------------------------------+
| Запрос к несуществующему адресу: /item/404 |
+------------------------------------------------------------------------+
|
v
+------------------------------------------------------------------------+
| Edge CDN / Reverse Proxy (Cloudflare Worker / Nginx) |
+------------------------------------------------------------------------+
| |
[Классический SPA-сервер] [Edge Routing Gate]
| |
v v
+---------------------------+ +----------------------------------+
| Возврат HTTP 200 OK | | Проверка валидности slug в Edge |
| + index.html | | KV / Headless API кэше |
+---------------------------+ +----------------------------------+
| | |
v [Slug найден] [Slug не найден]
+---------------------------+ | |
| Запуск React Router | v v
| Рендер <NotFound /> | +---------------+ +---------------+
+---------------------------+ | Пропуск к SPA | | Немедленный |
| | HTTP 200 OK | | HTTP 404 |
v +---------------+ | с кастомным |
+---------------------------+ | HTML-экраном |
| Поисковик видит 200 OK: | +---------------+
| Ошибка SOFT 404! |
+---------------------------+Ключевые понятия:
Пользователь кликает по <Link to='/catalog'>. Роутер перехватывает клик, предотвращая полную перезагрузку страницы.
Адресная строка обновляется через pushState. Поисковый краулер этот шаг не видит — он использует только исходный href ссылки.
React монтирует целевое представление, вычисляет абсолютный канонический URL и поднимает его в <head>.
При отсутствии соответствия маршруту активируется ветка NotFound с инъекцией директивы noindex.
Почему стандартная SPA-раздача статики через try_files в Nginx приводит к возникновению ошибок Soft 404 для поисковых ботов?
?utm_source=, ?yclid=, ?sort=price) и синхронизация канонических тегов с базовым адресом товарной категории./blog?page=2) с сохранением самореферентных канонических ссылок и исключением склейки со страницей /blog.X-Robots-Tag: Добавление заголовка ответа сервера для страниц поиска и закрытых разделов фильтрации.Наиболее надежный способ предотвратить Soft 404 в SPA без полноценного SSR — использование промежуточного воркера на границе сети (Edge Worker), который проверяет маршрут по кэшированному индексу путей.
interface Env {
VALID_SLUGS_KV: KVNamespace;
STATIC_SPA_ORIGIN: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname;
// Игнорируем проверку статических ассетов
if (pathname.match(/\.(js|css|png|jpg|svg|ico|json|woff2)$/)) {
return fetch(request);
}
// Проверка динамических сегментов, например /products/:slug
const productMatch = pathname.match(/^\/products\/([^/]+)$/);
if (productMatch) {
const slug = productMatch[1];
const isValidSlug = await env.VALID_SLUGS_KV.get(`product:${slug}`);
if (!isValidSlug) {
// Отдаем настоящий HTTP 404 Not Found со статическим каркасом ошибки
return new Response(generateStatic404Html(pathname), {
status: 404,
statusText: 'Not Found',
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'no-store',
},
});
}
}
// Маршрут валиден — передаем запрос к стандартному SPA-хостингу
const response = await fetch(request);
return response;
},
};
function generateStatic404Html(path: string): string {
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>404 — Страница не найдена</title>
<meta name="robots" content="noindex, nofollow">
<style>
body { font-family: sans-serif; background: #09090b; color: #fafafa; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { text-align: center; border: 1px solid #27272a; padding: 2rem; border-radius: 0.5rem; }
a { color: #38bdf8; text-decoration: none; font-weight: bold; }
</style>
</head>
<body>
<div class="card">
<h1>404 — Страница не найдена</h1>
<p>Запрошенный адрес <code>${escapeHtml(path)}</code> не существует.</p>
<a href="/">Вернуться на главную</a>
</div>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (match) => {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
return map[match] || match;
});
}noindex при рендере 404#Если запрос уже дошел до клиентского приложения и React Router активирует маршрут path="*", компонент NotFound обязан мгновенно выставить метатег noindex и сигнализировать системе пререндеринга.
import React, { useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
export function NotFoundPage() {
const location = useLocation();
useEffect(() => {
// Сигнал для headless-воркера пререндеринга о коде статуса
let metaPrerenderStatus = document.querySelector('meta[name="prerender-status-code"]');
if (!metaPrerenderStatus) {
metaPrerenderStatus = document.createElement('meta');
metaPrerenderStatus.setAttribute('name', 'prerender-status-code');
document.head.appendChild(metaPrerenderStatus);
}
metaPrerenderStatus.setAttribute('content', '404');
return () => {
// Очистка при переходе на валидный роут
metaPrerenderStatus?.remove();
};
}, []);
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 p-6 text-slate-100">
{/* Нативный подъем метаданных React 19 */}
<title>404: Страница не найдена | Fullstack Agentic Lab</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="prerender-status-code" content="404" />
<div className="max-w-md w-full border border-slate-800 rounded-xl p-8 bg-slate-900 text-center">
<span className="text-sm font-mono uppercase tracking-widest text-rose-500 font-bold">
Ошибка 404
</span>
<h1 className="text-2xl font-bold mt-2">Страница не найдена</h1>
<p className="mt-4 text-slate-400 text-sm">
Адрес <code className="text-cyan-400">{location.pathname}</code> не существует или был перемещен.
</p>
<Link
to="/"
className="mt-6 inline-block px-6 py-2.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium text-sm transition-colors"
>
На главную
</Link>
</div>
</main>
);
}Канонический URL должен строиться строго по канонической маске домена без мусорных параметров фильтрации, пагинации и меток аналитики.
import { useLocation } from 'react-router-dom';
import { useMemo } from 'react';
const ALLOWED_QUERY_PARAMS = new Set(['page']);
interface CanonicalOptions {
baseOrigin?: string;
allowedParams?: readonly string[];
}
export function useCanonicalUrl({
baseOrigin = 'https://agenticlab.dev',
allowedParams = ['page'],
}: CanonicalOptions = {}): string {
const location = useLocation();
return useMemo(() => {
const url = new URL(location.pathname, baseOrigin);
const searchParams = new URLSearchParams(location.search);
const cleanParams = new URLSearchParams();
// Сохраняем только разрешенные SEO-значимые параметры
for (const key of allowedParams) {
const val = searchParams.get(key);
if (val !== null) {
cleanParams.set(key, val);
}
}
const queryString = cleanParams.toString();
return queryString ? `${url.origin}${url.pathname}?${queryString}` : `${url.origin}${url.pathname}`;
}, [location.pathname, location.search, baseOrigin, allowedParams]);
}Как правильно настроить тег Canonical URL для страницы каталога товаров с пагинацией (/catalog?page=2)?
Указание относительного адреса <link rel="canonical" href="/products/shoes"> противоречит спецификации RFC 6596. Поисковые краулеры могут неверно разрешить домен или проигнорировать тег, что приведет к неконтролируемому созданию дублей страниц.
Решение: Канонический адрес обязан быть абсолютным и содержать протокол, канонический субдомен и чистый путь: https://agenticlab.dev/products/shoes.
Использование обработчиков onClick={() => navigate('/catalog')} на элементах <div>, <span> или <button> полностью скрывает связи страниц от поисковых роботов. Робот парсит атрибуты href тегов <a>. В результате разделы сайта остаются не найденными.
Решение: Всегда используйте компонент <Link to="/path"> или нативный <a href="/path"> с семантическим адресом.
Если на страницах пагинации /catalog?page=2, /catalog?page=3 указан канонический тег на первую страницу <link rel="canonical" href="https://site.com/catalog">, поисковый робот исключит страницы 2, 3 и последующие из индекса, а ссылки на товары, расположенные на них, перестанут индексироваться.
Решение: Страницы пагинации должны иметь самореферентный канонический URL (например, https://site.com/catalog?page=2).
При обнаружении несуществующего пути роутер делает navigate('/', { replace: true }). Краулер переходит по несуществующей ссылке, получает редирект на главную с кодом 200, фиксирует Soft 404 и снижает поисковый рейтинг сайта за некорректную структуру навигации.
Решение: Никогда не делайте скрытые редиректы со сломанных ссылок на корень. Отображайте страницу 404 с тегом noindex или отдавайте статус 404 на уровне Edge/Reverse Proxy.