0 líneas de javascript para un crawler: los bytes de html que bing lee en lugar de mi app react
dev · Oct 4, 2026 · 6 min read
este sitio es una app react renderizada en cliente. envía un archivo html. hay 539 urls en el sitemap y todas se sirven con ese mismo documento, porque no hay /blog/index.html ni /it/index.html — hay exactamente un archivo html en la salida de compilación y pesa 12.245 bytes.
entonces, ¿qué ve bing?
un crawler que no ejecuta javascript mira ese documento y encuentra un <head> perfectamente bueno y un <div id="root"> vacío. 10.377 bytes de metadatos y cero palabras de contenido.
el arreglo fue dejar de enviar un div vacío.
el respaldo
33 líneas de html plano dentro de #root, que react descarta al montar:
| medición | valor |
|---|---|
| líneas | 33 |
| bytes | 1.416 |
| palabras | 101 |
<h1> / <h2> / <li> |
1 / 2 / 7 |
| enlaces | 7 |
etiquetas <script> |
0 |
un h1, un párrafo corto que dice quién soy y qué hago, una lista de «servicios», una lista de «contacto», y cuatro enlaces dentro del sitio. 1.416 bytes de copy de marketing que mantengo a mano, para siempre, para un lector que nunca los mirará.
más 6 reglas de css — 13 líneas, 543 bytes — para que llegue con aspecto de página pequeña en lugar de página rota. esa parte no es decoración. un respaldo renderizado como texto negro sobre blanco sin estilos le dice a un revisor humano que este sitio está roto, y esos mismos 543 bytes son la diferencia entre este sitio tiene contenido mínimo y este sitio está roto.
por qué no es un duplicado de la página
porque createRoot limpia su contenedor. esto es todo lo que hace el código de montaje:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
react no fusiona dentro de #root, borra los hijos y pone los suyos. así que el visitante nunca ve el respaldo más de unos milisegundos, y el crawler nunca ve react. no hay desajuste de hidratación que temer ni estado «¿la app está lista?» que diseñar.
el compromiso, dicho claramente
al principio no hice prerender. astro, o next con exportación estática, o vite-ssg habrían dado a cada url su propio html con su propio texto. no lo tomé, porque convierte una página de marketing en una segunda cadena de compilación con su propia caché y su propio modo de fallo de despliegue.
luego fui a medir cuánto costaba de verdad ese suelo, y la respuesta era peor de lo que suponía: 374 urls, y todas eran el mismo documento. el canonical de las 374 decía la raíz del sitio, que es una forma de decirle al rastreador que las otras 373 son duplicados de la portada. un documento, 374 direcciones.
así que escribí un prerenderizador. emite un archivo html por cada par (ruta, idioma) dentro de la salida de compilación, generado a partir de los mismos datos que lee la app, así que los dos no pueden discrepar. pnpm run build ahora escribe 189 documentos — 7 rutas de página × 7 idiomas, y un documento por entrada e idioma en el que esa entrada se ha traducido. un idioma aporta una url en cuanto esa entrada está escrita, y el grupo hreflang de una entrada se calcula a partir de su propio id, así que el documento y sus alternates nunca pueden discrepar sobre qué idiomas existen.
aquí está la cuenta honesta, por url, ahora:
| qué | ¿basta la ruta? |
|---|---|
<title>, description, canonical, hreflang |
sí — estático en el head, uno por url |
| JSON-LD | sí — BlogPosting y BreadcrumbList en las entradas, WebPage en el resto |
| sitemap, rss | sí — 35 rutas, repartidas entre los dos grupos de idiomas |
| texto del body | sí en las entradas y en el índice del blog — el artículo entero, en ese idioma |
texto del body en /lab, /now, /lens |
en parte. título, descripción y navegación, pero no la prosa, porque esa prosa vive en jsx y no hay un archivo de datos del que leerla |
| la app | sí, un único bundle de javascript |
lo interesante no fue escribir el generador. fue descubrir que el grupo de hreflang tenía que ser por tipo de ruta. una entrada existe en tres idiomas y una página en siete, así que una lista fija de once estaba anunciando seis idiomas que servían el original en inglés bajo una etiqueta extranjera — que google cuenta como contenido duplicado, no como localización.
las dos listas ahora se derivan de los archivos de módulo en disco. soltar posts/fr.ts hace aparecer el francés, y no hay que cambiar nada más.
el suelo desapareció para las entradas. para tres páginas sigue siendo un suelo, y prefiero decirlo antes que fingir lo contrario.
lo que tomé en su lugar es un suelo. un crawler recibe algo donde pisar en lugar de nada. aquí está la contabilidad honesta de lo que es cierto y lo que es falso, por url:
| qué | específico de la ruta? |
|---|---|
<title>, description, canonical, hreflang |
estáticos en el head, y sí — uno por url |
JSON-LD (Person, WebSite, ProfilePage) |
sí |
| sitemap, rss | sí, 35 rutas, sobre los dos grupos de idiomas |
| texto del cuerpo | no. es la portada, en todas ellas |
| la app | sí, un bundle de javascript |
el head es real. el cuerpo es un suelo. para un sitio personal es un trato que puedo aceptar, y para un sitio cuyo producto es el texto del cuerpo no lo es, y el mismo código sería un error.
la capa por encima del respaldo, y los dos bugs que tiene
el head no es lo único específico de la ruta. /blog/* tiene una cloudflare pages function que reescribe los metadatos para bots conocidos:
const BOT_AGENTS = [
"Twitterbot", "facebookexternalhit", "LinkedInBot", "Slackbot",
"TelegramBot", "WhatsApp", "Discordbot", "discordbot",
"ia_archiver", "Googlebot", "bingbot", "Applebot",
];
doce user agents. para esos, [id].js busca la entrada en un og-data.js generado y luego reescribe <title>, og:title, og:description, og:url, og:type, las etiquetas de twitter y la meta description mediante HTMLRewriter. bingbot está en la lista, y bingbot es el que no ejecuta javascript. así que para una entrada del blog, bing recibe el título real de la entrada real.
y luego recibe las 101 palabras de la portada debajo.
bug 1: DuckDuckDuckBot no está en esa lista. doce entradas, y el crawler que nombré en el comentario justo encima del bloque de respaldo — la razón de ser de todo el respaldo — es el que cae en los valores por defecto. todas las entradas del sitio se titulan Enea | Blog para duckduckgo. Una cadena. Eso es todo el arreglo y no lo he aplicado.
bug 2: el canonical nunca se reescribe. el mapa en MetaRewriter incluye og:url pero no hay manejador para link[rel=canonical]. así que el mismo documento dice a las plataformas sociales «esta url es /blog/mi-entrada``* y dice a los motores *«esta url es https://eneawork.it/``. og:url y rel=canonical no pueden discrepar, y los míos discrepan en todas las entradas. el arreglo es una entrada más en el mapa y un .on("link[rel=canonical]") más.
más pequeño: la función reescribe twitter:card a summary_large_image mientras og:image sigue siendo la foto de perfil de 460×460, porque todas las entradas comparten una imagen. una tarjeta grande con un avatar cuadrado dentro.