Los routers del lado del cliente modernos (React Router v7, TanStack Router) gestionan las transiciones entre vistas empleando la History API. Cuando un usuario hace clic en un enlace interno, el router intercepta el evento nativo del navegador (e.preventDefault()), actualiza la barra de direcciones mediante history.pushState() y re-renderiza la rama de componentes correspondiente.
Para un usuario humano, la transición es prácticamente instantánea. Sin embargo, para los rastreadores de motores de búsqueda, esta mecánica ocasiona dos graves problemas arquitectónicos:
pushState. Extraen rutas absolutas de los atributos href de las etiquetas estándar <a href="..."> en el HTML estático y emiten solicitudes HTTP GET independientes para cada URL. Si los enlaces se implementan mediante elementos no semánticos (como <button onClick="..."> o rutas basadas en hash <a href="#/catalogo">), los rastreadores jamás descubrirán las páginas de destino.index.html con un código de respuesta HTTP 200 OK:
try_files $uri $uri/ /index.html;/products/deleted-slug-999), el servidor responde con 200 OK. A continuación, se descarga el bundle, arranca el router de React, detecta que el recurso no existe y renderiza un componente <NotFound />. El robot de búsqueda registra el código 200 OK e indexa una pantalla de error como un documento legítimo.Soft 404 causa daños irreparables: Googlebot gasta su cuota diaria de rastreo inspeccionando miles de URLs fantasma, y los textos duplicados de "Producto no encontrado" diluyen la autoridad temática del dominio.
Para una indexación óptima de una SPA en motores de búsqueda, se requiere una sincronización estricta en tres niveles: la capa de transporte de red (Código de Estado HTTP), la barra de direcciones del navegador (URL Canónica) y el estado en memoria del router cliente.
+------------------------------------------------------------------------+
| Solicitud a Dirección Inexistente: /item/404 |
+------------------------------------------------------------------------+
|
v
+------------------------------------------------------------------------+
| Edge CDN / Reverse Proxy (Cloudflare Worker / Nginx) |
+------------------------------------------------------------------------+
| |
[Servidor Clásico SPA] [Edge Routing Gate]
| |
v v
+---------------------------+ +----------------------------------+
| Responde HTTP 200 OK | | Comprobación de slug en Edge |
| + index.html | | KV / Caché de API Headless |
+---------------------------+ +----------------------------------+
| | |
v [Slug Válido] [Slug Inexistente]
+---------------------------+ | |
| Se ejecuta React Router | v v
| Renderiza <NotFound /> | +---------------+ +---------------+
+---------------------------+ | Pasa a SPA | | Emisión |
| | HTTP 200 OK | | Inmediata |
v +---------------+ | HTTP 404 con |
+---------------------------+ | HTML Estático |
| El bot ve 200 OK: | +---------------+
| ¡Discrepancia SOFT 404! |
+---------------------------+Conceptos fundamentales:
?utm_source=, ?ref=, ?sort=price) y alineación de etiquetas canónicas hacia la categoría raíz./blog?page=2) previniendo su consolidación errónea sobre /blog.X-Robots-Tag: Emisión de cabeceras HTTP de servidor para endpoints de búsqueda y filtros facetados.La estrategia más sólida para erradicar el Soft 404 en una SPA sin implementar SSR completo es desplegar un Edge Worker que valide las rutas dinámicas contra un índice distribuido en memoria.
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;
// Ignorar inspección para recursos estáticos
if (pathname.match(/\.(js|css|png|jpg|svg|ico|json|woff2)$/)) {
return fetch(request);
}
// Inspección de rutas dinámicas como /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) {
// Emitir un estado HTTP 404 Not Found real con shell estático
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',
},
});
}
}
// Ruta confirmada como válida: delegar en el hosting estático de la SPA
return fetch(request);
},
};
function generateStatic404Html(path: string): string {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>404 — Página no encontrada</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 — Página no encontrada</h1>
<p>La ruta solicitada <code>${escapeHtml(path)}</code> no existe.</p>
<a href="/">Volver al Inicio</a>
</div>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (match) => {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
return map[match] || match;
});
}noindex Dinámico en Pantallas 404#Si la solicitud ya ha sido recibida por la aplicación cliente y React Router activa la rama comodín path="*", el componente NotFound debe inyectar de inmediato la directiva noindex y señalar al sistema de pre-renderizado.
import React, { useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
export function NotFoundPage() {
const location = useLocation();
useEffect(() => {
// Señal para el cluster de pre-renderizado headless sobre el código de estado
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 () => {
// Limpieza de etiqueta al navegar a una ruta válida
metaPrerenderStatus?.remove();
};
}, []);
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 p-6 text-slate-100">
{/* Elevación nativa de metadatos de React 19 */}
<title>404: Página no encontrada | 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">
Error 404
</span>
<h1 className="text-2xl font-bold mt-2">Página no encontrada</h1>
<p className="mt-4 text-slate-400 text-sm">
La dirección <code className="text-cyan-400">{location.pathname}</code> no existe o ha sido reubicada.
</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"
>
Ir al Inicio
</Link>
</div>
</main>
);
}La URL canónica debe construirse sobre un origen determinista, eliminando parámetros de consulta ajenos al contenido y reteniendo únicamente parámetros esenciales como la paginación.
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();
// Conservar únicamente parámetros de consulta con valor 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]);
}Indicar una dirección relativa como <link rel="canonical" href="/products/shoes"> infringe la especificación RFC 6596. Los rastreadores pueden interpretar dominios incorrectos o ignorar la etiqueta, fragmentando la indexación.
Solución: La URL canónica debe ser absoluta e incluir protocolo, subdominio autorizado y ruta normalizada: https://agenticlab.dev/products/shoes.
Usar manejadores onClick={() => navigate('/catalog')} en elementos <div>, <span> o <button> oculta la estructura de enlaces a los rastreadores. Los bots extraen atributos href de etiquetas <a>. Como resultado, secciones enteras quedan fuera del índice.
Solución: Emplear siempre <Link to="/path"> o etiquetas nativas <a href="/path"> con URLs semánticas.
Asignar <link rel="canonical" href="https://example.com/catalog"> en /catalog?page=2 indica al buscador que las páginas secundarias son duplicados exactos de la primera. El buscador excluye las páginas paginadas y deja de rastrear los productos enlazados en ellas.
Solución: Las páginas paginadas deben contar con URLs canónicas autorreferenciales (ej. https://example.com/catalog?page=2).
Al detectar una ruta rota, ejecutar navigate('/', { replace: true }) provoca que el bot siga el enlace, reciba un código 200 en la home, registre una penalización por Soft 404 y deprecie la calidad estructural del sitio.
Solución: Jamás redirigir rutas rotas silenciosamente a la raíz. Mostrar una vista 404 con directiva noindex o emitir el código 404 en el Edge/Reverse Proxy.
El usuario pulsa un <Link to='/catalog'>. React Router intercepta el evento evitando la recarga completa.
La barra de direcciones se actualiza con pushState. Los rastreadores se saltan este paso y piden la URL vía HTTP.
React renderiza la vista destino, calcula la URL canónica absoluta y la eleva al head del documento.
Ante una discrepancia de ruta, se monta el componente NotFound e inyecta la directiva noindex.