QA técnico de un sitio Astro + Sanity con Claude Code: 22 hallazgos, build 4× más rápido y Bunny Stream blindado
Terminamos de migrar nuestro propio sitio a Astro + Sanity. El build daba verde y el sitio se veía bien. Lo auditamos igual con Claude Code y encontramos 22 problemas que ninguna herramienta automática marcaba, más un CDN de video abierto a cualquiera.
El build daba verde. Terminamos de migrar mentaexperts.com de Webflow a Astro + Sanity. 216 páginas, bilingüe, todo estático. astro check limpio, astro build limpio, el sitio se veía bien.
Auditamos el código igual, con Claude Code, en siete ejes: bugs, accesibilidad, rendimiento, SEO, seguridad, duplicación y deuda técnica. Aparecieron 22 problemas que ninguna herramienta automática marcaba. El build pasó de 6m36s a 1m38s, y de paso descubrimos que los videos de Bunny Stream estaban abiertos a que cualquiera consumiera ancho de banda facturado.
Esto es lo que encontramos, cómo lo encontramos, y por qué un build verde no significa nada.
Por qué auditar algo que ya funciona
El sitio estaba terminado. Cero errores de tipos, cero warnings, build exitoso, las páginas se veían idénticas al diseño. Por cualquier criterio automático, listo para producción.
Decidimos auditarlo de todas formas, entero: bugs, accesibilidad, rendimiento, SEO, seguridad, duplicación. No una pasada rápida, sino revisar cada archivo y —esto resultó ser lo importante— medir el output real en vez de confiar en que el código dice lo correcto.
Aparecieron 22 problemas. Ninguno lo detectaba el compilador. La mayoría llevaba semanas en el repositorio.
Vale la pena entender por qué. TypeScript verifica que los tipos encajen. El build verifica que el código compile. Ninguno de los dos verifica que el resultado sea correcto. Un href puede apuntar a una ruta que no existe y compilar perfecto. Una plantilla puede emitir siete <h1> y ser HTML válido. Un archivo puede pedir la misma información mil veces y terminar sin errores.
Esa distinción —código válido contra resultado correcto— es la que atraviesa todo lo que sigue.
1. Lo que un build verde no ve
El favicon que daba 404 en las 215 páginas
El layout base tenía esto:
El archivo se llama favicon.png. Sin el _menta.
Es un string. Astro no lo valida, TypeScript no lo mira, el build ni se entera. El sitio no tuvo favicon durante toda la migración y nadie lo notó, porque en desarrollo el navegador muestra un ícono genérico y uno deja de verlo.
Lo encontramos con un script de veinte líneas: recorrer los HTML generados, extraer cada href y src que empiece con /, y comprobar contra los archivos reales del build. Un destino roto en todo el sitio. Ese.
De paso apareció que webclip.png existía sin que nadie lo referenciara: faltaba el apple-touch-icon.
Hasta siete <h1> en un mismo artículo
Los 45 artículos del blog venían migrados desde el rich text de Webflow. El renderizador de Portable Text tenía definidos h2, h3, blockquote y normal. Nada más.
¿Qué pasa con los h1 que traía el contenido? Caen al renderizador por defecto y salen como <h1> crudos —sin clase del design system— encima del <h1> del título que ya pone la plantilla.
El peor caso:
Doce artículos afectados, por dos idiomas. Contando el HTML generado: 18 `<h1>` sin clase, 17 `<h4>` y 2 tablas que caían al renderizador por defecto y salían con la tipografía default del navegador.
La solución no fue borrarlos sino degradarlos a h2: quedan al mismo nivel que el resto de las secciones, entran en la tabla de contenidos, y el título vuelve a ser el único h1 de la página.
92 de 214 URLs del sitemap sin hreflang
Esta es la más cara de las tres, y la más instructiva.
El sitio es bilingüe con slugs propios por idioma: /services/brand-design en inglés, /es/servicios/diseno-de-marca en español. No es un prefijo, es otro slug. Así que el hreflang del sitemap hay que armarlo a mano trayendo los pares reales desde el CMS.
Ese mapa vivía en astro.config.mjs y tenía un comentario propio que decía:
Se desincronizaron igual. Cuando se agregó el blog, entró en sanity.ts y no en el config. Resultado: los 45 artículos en inglés, los 45 en español y los dos índices salían del sitemap sin par de idioma. 92 de 214 URLs declarando que no tienen versión alternativa.
La lección no es "pon un comentario". El comentario estaba. La lección es que un comentario no es un mecanismo: si dos lugares tienen que coincidir, o comparten la fuente o se van a separar.
2. Seis minutos y medio de build, con el CPU al 2%
El build tardaba 6 minutos 36 segundos para 216 páginas. Casi dos segundos por página, en un sitio estático sin imágenes procesadas en build.
El dato que cierra el diagnóstico es el otro:
2% de CPU. No estaba compilando: estaba esperando.
El shell del sitio —layout, navbar, footer, sección CTA— pedía los mismos tres documentos en cada página:
Seis a siete consultas idénticas × 216 páginas ≈ 1.400 viajes al CMS devolviendo exactamente lo mismo. Y como el cliente estaba con useCdn: false —correcto, para no servir contenido viejo recién publicado— cada una salía a la red de verdad.
El arreglo son once líneas:
Se cachea la promesa, no el resultado: si dos páginas se renderizan en paralelo comparten el request en vuelo en vez de disparar dos. El caché vive en el módulo, así que dura lo que dura el proceso —cada build arranca limpio y useCdn: false sigue garantizando contenido fresco.
Aplicado a las 22 consultas sin parámetros:
Cuatro veces más rápido. El tiempo de renderizado por página cayó de 1.850 ms a 4 ms. No cambió una sola línea de markup.
3. El <div> que parecía un botón
Los acordeones de FAQ del sitio se veían y se comportaban como acordeones. Hacías clic, se abrían.
El markup era este:
Sin role. Sin tabindex. Sin aria-expanded. Sin manejo de teclado.
Con mouse funciona. Con teclado no existe: el Tab pasa de largo, Enter no hace nada. Para un lector de pantalla es un <div> con texto adentro; no anuncia que es interactivo ni si está abierto o cerrado.
Estaba en ocho páginas, incluida la home.
Lo interesante es que el sitio ya tenía el patrón correcto implementado. El megamenú usaba role="button", tabindex="0", manejo de Enter y Espacio, Escape para cerrar y gestión de foco. Alguien lo hizo bien una vez y no se replicó.
Como el markup del acordeón estaba duplicado en ocho archivos y el comportamiento vivía completo en un solo site.js, aplicamos la mejora ahí:
Un lugar, ocho páginas arregladas, cero riesgo de que se escape un archivo. Más un anillo de :focus-visible, porque el outline por defecto del navegador es invisible sobre fondo oscuro.
4. Variables de CSS que no existían
El selector de idioma tenía esto:
Se ve como código que usa el design system. No lo es. Ninguna de las dos variables está definida en ningún CSS del proyecto. Siempre gana el fallback: son colores hardcodeados con disfraz.
Y el disfraz es el problema. Un #3bbfad suelto se ve raro en una revisión y alguien pregunta. Un var(--color--menta, #3bbfad) parece correcto y nadie lo mira dos veces. El día que cambie el verde de marca, ese punto no se entera.
Lo detectamos comparando conjuntos: extraer todas las variables *usadas* en el CSS y en los componentes, extraer todas las *definidas*, y restar. Dos huérfanas.
En el mismo barrido apareció que el verde de marca estaba escrito literal en cinco reglas del CSS propio, teniendo el token --_primitives---colors--menta con exactamente ese valor.
Y algo peor: 125 `<div>` de estrellas decorativas copiados a mano en once archivos, cada uno con su rgba() y sus píxeles inline:
Doce estrellas únicas, repetidas diez veces. Cambiar la densidad significaba editar once archivos. Ahora es un componente y el tamaño y el color los resuelve el CSS.
5. 8.422 líneas para 13 páginas
El hallazgo estructural. El sitio es bilingüe y cada página existía dos veces como archivo: index.astro y es/index.astro, about-us.astro y es/sobre-nosotros.astro, y así.
No eran dos archivos distintos. Era el mismo archivo copiado con las palabras cambiadas:
Trece pares: 8.422 líneas repartidas en 26 archivos, de las cuales alrededor de 3.400 eran duplicación pura.
El costo no es el espacio en disco. Es que todo arreglo visual hay que hacerlo dos veces, y si te olvidas de una el otro idioma queda mal sin que nada avise. Ya nos había pasado: el bug de los 92 hreflang fue exactamente eso.
La solución es la obvia —un componente compartido que recibe locale, dos rutas de ocho líneas— pero aplicarla a 26 archivos a mano es justo el tipo de tarea donde se cuelan errores silenciosos. Así que escribimos una herramienta que alinea los dos archivos línea a línea, extrae los nodos de texto que cambian a un diccionario, y emite el markup único.
Y ahí empieza la parte interesante.
6. Cómo verificar un refactor que toca 8.000 líneas
Esta es la sección que más nos sirvió a nosotros.
La herramienta funcionaba: astro check daba 0 errores, astro build generaba las 216 páginas. Por cualquier criterio automático, el refactor estaba bien.
No lo estaba.
El método que lo destapó es simple: guardar el `dist/` antes del refactor, rebuildear, y comparar el output. No los archivos crudos —el formato cambia por motivos irrelevantes— sino lo que importa: el texto visible con los tags eliminados, y cada href y src. Normalizando de antemano las diferencias que sí son intencionales.
Si el contenido y los links salen idénticos, el refactor es correcto. Si no, hay algo que revisar. No es una heurística: es el resultado real.
Encontró cuatro bugs que ni los tipos ni el build veían.
Bug 1: la página en español enlazaba a rutas en inglés
El extractor comparaba nodos de texto. Un href dentro de una expresión JavaScript no es un nodo de texto, así que no lo veía —y como en esa misma línea sí había texto que cambiaba, tampoco saltaba ninguna alarma. Emitía la versión inglesa en silencio.
Bug 2: un componente convertido en string
En la home, la línea del CTA final estaba escrita distinto en cada idioma: <CtaSection /> contra <CtaSection locale={locale} />. La herramienta la trató como texto localizable y la guardó en el diccionario:
Inyectado con set:html, el navegador ve un elemento desconocido y no renderiza nada. La sección CTA desaparecía de la home, sin error de compilación ni de consola.
Bug 3: la meta description mostrando el párrafo del hero
Las claves del diccionario se generaban a partir del texto. Dos frases distintas de la página de contacto empezaban igual —"Schedule a consultation to…"— y produjeron la misma clave. La segunda pisó a la primera.
Resultado: el <meta name="description"> de /contact y /es/contacto mostraba el párrafo del hero, con los <br /> incluidos.
Bug 4: nueve páginas con el frontmatter entero sin traducir
El peor de los cuatro. La herramienta alineaba los frontmatter comparando cantidad de líneas; si no coincidía, descartaba y se quedaba con el inglés.
Los archivos en inglés tenían comentarios explicativos largos que los de español no repetían. Nueve de las diez páginas no coincidían. El diccionario completo de textos quedaba en inglés.
El arreglo fue alinear ignorando comentarios y líneas en blanco. Pero lo relevante es cómo apareció: no lo vio el compilador, lo vio la comparación del HTML generado.
Fallar ruidosamente
Cada bug se arregló dos veces: el caso puntual, y la herramienta para que ese modo de fallo no pudiera repetirse en silencio.
Después de esos controles, la herramienta empezó a abortar en casos que antes pasaba por alto. Cada aborto era un bug que no se publicó.
Resultado final: 0 de 216 páginas con diferencias de contenido o de links. Las únicas diferencias que quedaron son dos, ambas buscadas: los apóstrofos ahora salen como ' (al venir de una variable, Astro los escapa; se renderiza idéntico) y la resolución de los videos del grid.
De paso, la comparación destapó dos bugs que ya existían antes del refactor y que nadie había visto: el mapa del sitio en español enlazaba a /es/what-we-do y /es/industries —rutas que no existen— y la meta description de contacto ya estaba mal.
7. Lo que decidimos no hacer
Dos hallazgos quedaron abiertos a propósito. Los dos son de seguridad, y en los dos hacerlo mal es peor que no hacerlo.
La CSP quedó en `Report-Only`. Una Content-Security-Policy es una lista blanca de orígenes permitidos: la mejor defensa contra inyección de código. Nuestro sitio carga del CMS, del CDN de video, de embeds de YouTube y Vimeo, y del servicio de formularios. Si escribimos la lista de memoria y falta uno, ese recurso deja de cargar sin ningún error visible: la imagen no aparece, el formulario no envía, y te enteras cuando un cliente no puede contactarte.
En Report-Only el navegador no bloquea nada, solo anota lo que habría bloqueado. Dos semanas con tráfico real, se revisa el reporte, y recién ahí se activa con la lista validada contra lo que el sitio realmente usa.
El HSTS quedó comentado. Fuerza HTTPS en el dominio durante todo el max-age. Una vez que un navegador cachea ese header, no hay forma de revertirlo desde el servidor: hay que esperar a que expire. Si se activa antes de que el dominio definitivo esté sirviendo HTTPS de forma estable, cualquier subdominio sin certificado queda inaccesible por un año.
Las dos entraron al checklist de lanzamiento con el motivo escrito. Un pendiente documentado y con fecha es distinto de un pendiente olvidado.
8. Los videos estaban abiertos a cualquiera
El portfolio usa Bunny Stream para los videos de fondo. La auditoría de seguridad empezó por lo obvio —cabeceras, secretos en el repo, escapado de contenido del CMS— y terminó en algo que no habíamos pensado.
Un curl pelado contra la URL del MP4:
Sin navegador, sin Referer, sin token. 200 y 3,3 MB. Y todas las resoluciones abiertas, hasta 1080p a 6,9 MB.
Bunny factura por ancho de banda entregado. Cada descarga se paga, venga de donde venga.
La CSP no protege esto
Vale aclararlo porque es un error común: una Content-Security-Policy es una instrucción que el navegador aplica dentro de tus páginas. Controla qué puede cargar tu sitio. No tiene forma de impedir que alguien pegue la URL del video en otro lado ni que un script la descargue en bucle. El bot ni pasa por tu web.
Lo que sí protege es la hotlink protection del Pull Zone: una lista blanca de hostnames que se compara contra el Referer de cada request, más el bloqueo de acceso directo para las peticiones que llegan sin Referer —exactamente como pide un bot.
Dos detalles que cuestan una tarde
Bunny compara el hostname de forma exacta, con el puerto incluido. Lo medimos:
Dos cosas ahí. Primera: `localhost` no sirve como referrer —Bunny lo rechaza por no ser un dominio válido—, así que activar la protección rompe los videos en desarrollo. Segunda: local.mentaexperts.com y local.mentaexperts.com:4300 son entradas distintas; hay que agregar la que realmente manda el navegador.
La solución para desarrollo es apuntar un hostname real a 127.0.0.1 en /etc/hosts, agregarlo a la lista blanca, y decirle a Vite que lo acepte:
El techo de gasto no está donde parece
El auto-recharge de la facturación no es un límite: recarga saldo cuando baja de cierto punto, que es lo contrario de lo que necesitas. El tope real es el monthly bandwidth limit del Pull Zone: cuando se alcanza, Bunny desactiva la zona y deja de cobrar.
Lo calculamos contra la tarifa más cara de nuestros mercados (Sudamérica, $0,045/GB) para que el techo se cumpla en el peor caso, y lo complementamos con límites por IP —que frenan a un abusador sin apagar el sitio para todos.
Y de paso, la mitad del peso
Los previews del grid se veían dentro de cards de ~600 px y se servían en 720p. Bajarlos a 480p es indistinguible a ese tamaño y pesa la mitad. Los heroes de las páginas de detalle se quedaron en 720p: ahí sí se ven a ancho completo.
Un dato que apareció al medir y que conviene tener presente: el CDN manda cache-control: max-age=2592000 —30 días—, así que el desarrollo local descarga cada video una vez al mes, no una vez por recarga. Recorrer el catálogo completo de 14 videos son 98 MB, menos de un centavo. No hacía falta optimizar nada para desarrollo, y medirlo evitó una complicación innecesaria.
Los números
Aparte: los 7 JPG que quedaban pasaron a AVIF a la misma resolución (2,49 MB → 0,47 MB, −81%), se borraron 48 imágenes que ningún archivo referenciaba, y GSAP y SplitType salieron de tres CDN distintas —con GSAP corriendo el core en 3.15.0 y su plugin ScrollTrigger en 3.14.2, algo que la propia librería no soporta— a versiones fijadas en package.json.
Lo que nos llevamos
Un build verde dice que el código compila, no que el resultado sea correcto. Los tres bugs de la primera sección —favicon, <h1>, hreflang— eran todos código perfectamente válido produciendo output equivocado. Ninguna herramienta de tipos los iba a encontrar.
Mide el output, no leas el código. La comparación del HTML generado encontró en minutos cuatro bugs que revisar los archivos a mano no había encontrado en horas. Si un refactor no debería cambiar el resultado, compruébalo: guarda el build, vuelve a compilar, compara.
Un comentario no es un mecanismo. El mapa de rutas duplicado tenía escrito "esto debe reflejar el otro archivo". Se separaron igual. Si dos lugares tienen que coincidir, o comparten la fuente o es cuestión de tiempo.
Cuando algo falla, arregla también el modo de fallo. Cada bug de la herramienta se corrigió dos veces: el caso puntual y la validación que impide que vuelva a pasar en silencio. Esa segunda parte es la que encontró los tres bugs siguientes.
Fallar ruidosamente le gana a funcionar callado. La versión de la herramienta que emitía inglés sin avisar era más "robusta" en apariencia: nunca se rompía. Era exactamente el problema.
Lo que no mides, lo supones mal en las dos direcciones. Creímos que el desarrollo local nos estaba costando plata en Bunny: eran centavos. Creímos que el sitio estaba razonablemente rápido: el build desperdiciaba cinco minutos esperando la misma respuesta mil cuatrocientas veces.
*Este artículo documenta la auditoría de mentaexperts.com, un sitio de 216 páginas en Astro + Sanity migrado desde Webflow, hecha con Claude Code en julio de 2026.*