Сучасні клієнтські роутери (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! |
+---------------------------+Ключові поняття:
?utm_source=, ?ref=, ?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-хостингу
return fetch(request);
},
};
function generateStatic404Html(path: string): string {
return `<!DOCTYPE html>
<html lang="uk">
<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]);
}Зазначення відносної адреси <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://example.com/catalog">, пошуковий робот виключить сторінки 2, 3 і наступні з індексу, а посилання на товари, розташовані на них, перестануть індексуватися.
Рішення: Сторінки пагінації повинні мати самореферентний канонічний URL (наприклад, https://example.com/catalog?page=2).
При виявленні неіснуючого шляху роутер робить navigate('/', { replace: true }). Краулер переходить за неіснуючим посиланням, отримує редирект на головну з кодом 200, фіксує Soft 404 і знижує пошуковий рейтинг сайту за некоректну структуру навігації.
Рішення: Ніколи не робіть приховані редиректи зі зламаних посилань на корінь. Відображайте сторінку 404 з тегом noindex або віддавайте статус 404 на рівні Edge/Reverse Proxy.
Користувач клікає по <Link to='/catalog'>. Роутер перехоплює клік, запобігаючи повному перезавантаженню сторінки.
Адресний рядок оновлюється через pushState. Пошуковий краулер цей крок не бачить — він використовує лише вихідний href посилання.
React монтує цільове представлення, обчислює абсолютний канонічний URL і піднімає його в <head>.
За відсутності відповідності маршруту активується гілка NotFound з ін'єкцією директиви noindex.