agentleFS
carlosuhartcarlosuhart/seo-audit-skills/skills/javascript-seo/SKILL.md

javascript-seo

Skill6 starsChanged 11 days agoMITView on GitHub

Auditoría técnica de sitios con frameworks JavaScript sin CMS (React/Next.js, Vue/Nuxt en Vercel o Netlify): hidratación incompleta, contenido oculto tras una interacción, soft-404 por rutas catch-all y canonical, hreflang y cabeceras configurados en el framework.

3 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

1 rule. 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • 0

    Common

    Most of the 3 edited versions we read keep these.

  • 0

    Distinctive

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    No hay un wp-admin, panel de plugin SEO, ni editor de contenido tradicional

    line 23

“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

JavaScript SEO — Guía Técnica (frameworks React/Vue, sin CMS)

A diferencia de WordPress/PrestaShop, aquí no hay un plugin SEO que resuelva meta tags, canonical o schema por defecto — cada señal depende de que el framework la genere explícitamente, y los bugs más graves no son de configuración sino de arquitectura de renderizado.


Cuándo aplica esta guía

Sitio construido con un framework de componentes (React, Vue, Svelte) donde:

  • No hay un wp-admin, panel de plugin SEO, ni editor de contenido tradicional
  • El HTML se genera por SSR/SSG (Next.js, Nuxt, Astro) o se hidrata en el cliente
  • El despliegue vive en Vercel, Netlify o similar, con configuración vía archivo de proyecto (next.config.js, vercel.json) en lugar de un panel de administración

Detección rápida: buscar en el HTML crudo (curl, sin ejecutar JS) marcadores del framework: self.__next_f.push(...) (Next.js App Router), <div id="__nuxt"> con contenido real dentro (Nuxt 3, que por defecto sí serializa HTML real vía SSR de Vue 3 — a diferencia de Next.js, aquí el shell vacío es la excepción, no la regla), o un <div id="root">/<div id="app"> casi vacío seguido de bundles JS grandes. Si el HTML crudo trae contenido real (headings, enlaces, texto), es SSR/SSG funcionando bien — si trae solo el shell y el contenido aparece al ejecutar JS, es CSR (client-side rendering) puro, con todos los riesgos de esta guía.

Nota Nuxt 2 vs Nuxt 3: el atributo data-server-rendered="true" es un marcador de Vue 2/Nuxt 2 (vue-server-renderer); Nuxt 3 (Vue 3, estándar desde 2022) ya no lo emite. No usarlo como señal en auditorías de sitios Nuxt actuales — si aparece, es indicio de un proyecto legado en Nuxt 2, dato en sí mismo relevante para el informe (stack sin soporte activo).

Detección de hosting vía cabeceras: Vercel expone X-Vercel-Cache/X-Vercel-Id; Netlify expone X-Nf-Request-Id/Server: Netlify. Confirmar con curl -sI antes de asumir el hosting a partir del dominio o de menciones externas — la cabecera es la única fuente verificable.


Hidratación incompleta — el bug más grave y menos obvio

Síntoma: navegación, encabezados (H1/H2) y/o datos estructurados JSON-LD no existen en el HTML que entrega el servidor — solo dentro del payload de hidratación que React/Vue usa para reconstruir el árbol en el cliente (patrón típico en Next.js: self.__next_f.push(['$', 'a', null, {href: '/ruta', children: 'Texto'}]) — la data existe, pero nunca se serializa como una etiqueta <a href> real en el HTML servido).

Esto no es lo mismo que "hace falta ejecutar JS para ver el contenido". Un sitio JS que hidrata bien produce, tras la ejecución, el mismo árbol semántico que si hubiera sido HTML estático desde el principio (<a href> reales, <h1> reales, <script type="application/ld+json"> reales). Cuando ni siquiera un crawler que ejecuta JS encuentra esas etiquetas, el problema es un hydration mismatch: el componente probablemente usa <div onClick={...}> en lugar de <a href="...">, o genera el heading/schema en un nodo que el diffing de hidratación nunca inserta al DOM real servido por el navegador. Confirmarlo en el código requiere que Desarrollo revise el componente — desde fuera solo se puede documentar el síntoma.

Método de verificación en 3 pasos, siempre los 3

  1. HTML crudo, sin JS: curl la URL y buscar los elementos esperados (grep -c "<h1", grep -c "application/ld+json", contar <a href=). Si da 0, no basta para concluir el bug — puede ser simplemente CSR normal.
  2. Crawler que ejecuta JS (Screaming Frog en modo JS Rendering, o el crawler de una herramienta de auditoría con soporte JS): si este también reporta 0 enlaces salientes / 0 H1 / 0 schema, el problema ya no es "falta renderizar" — es hidratación rota. Si el crawler JS sí encuentra el contenido pero el curl crudo no, es CSR normal (afecta a bots que no ejecutan JS, pero no es hidratación rota).
  3. Dato real de Google (URL Inspection API o informe de Cobertura en Search Console): confirma si Google, con su propio renderizado (segunda ola de indexación), está llegando al contenido o no. Un sitio con hidratación rota suele mostrar 0 páginas indexadas más allá de la home, o "Detectada, actualmente sin indexar" en cascada.

Por qué corregir esto resuelve varios síntomas de una sola vez: un solo bug de hidratación en un componente compartido (nav, header) puede romper simultáneamente navegación, jerarquía de encabezados y datos estructurados en todo el sitio — no tratarlos como 3 hallazgos independientes si la causa raíz es la misma.

Equivalente en Vue/Nuxt — no es el mismo bug, es una causa distinta

El SSR de Vue 3 (y por tanto de Nuxt 3) serializa el árbol real a HTML por defecto — no depende de un payload de hidratación separado como Next.js, así que el mismo síntoma (<a href>/<h1>/JSON-LD ausentes del HTML crudo) casi nunca viene de un "hydration mismatch" en el sentido de Next. En Vue/Nuxt, la causa real suele ser una de estas dos, y hay que distinguirlas en el hallazgo:

  1. SSR desactivado a nivel de proyecto (ssr: false en nuxt.config.ts, modo SPA puro) — en ese caso el HTML crudo trae el shell vacío (<div id="app"></div>) para todo el sitio, no solo para un componente. Confirmar revisando si existe nuxt.config.ts con esa flag o preguntando a Desarrollo antes de reportarlo como bug de hidratación.
  2. <ClientOnly> envolviendo navegación o headings, o contenido condicionado a onMounted()/process.client — el componente se salta el SSR a propósito, por lo que sus enlaces/headings nunca existen en el HTML servido aunque el resto de la página sí esté bien renderizada. Es la causa más común y más fácil de confirmar: buscar <ClientOnly> en el código del componente afectado, o el comentario vacío <!--[-->/<!--]--> que Vue 3 deja como marcador de fragmento no renderizado en el HTML crudo, en el lugar donde debería estar el contenido.

El método de verificación en 3 pasos de arriba (curl crudo → crawler con JS → dato real de Google) sigue aplicando igual; solo cambia a qué causa apunta el hallazgo en el informe.


Contenido gateado por interacción de UI — invisible a TODO crawler, incluidos los que ejecutan JS

Distinto del punto anterior: aquí el contenido si existe como HTML real y enlazable, pero solo se revela tras una interacción de usuario que ningún crawler simula — típicamente un menú desplegable (dropdown) que un botón con JavaScript abre al hacer click, y cuyos elementos internos nunca aparecen en el HTML servido hasta que ese click ocurre.

Esto rompe la asunción habitual de "un crawler que ejecuta JS ya ve todo lo que ve un usuario". No es así: un crawler con soporte JS renderiza la página, ejecuta scripts y espera a que la red se estabilice, pero no simula clicks arbitrarios de UI salvo en configuraciones muy específicas y limitadas (y ningún crawler de propósito general lo hace por defecto). Si el único camino hacia una URL es "click en este botón para desplegar el menú", esa URL queda huérfana para efectos prácticos de descubrimiento — sin importar cuán bien renderice JS el crawler.

Cómo se detecta (y por qué hace falta más de un método)

  1. curl sobre la página que contiene el menú: si los <a href> de los items del dropdown no aparecen en el HTML crudo, es la primera señal.
  2. Revisar el <footer> u otras rutas alternativas de descubrimiento por si el enlace existe en otro lugar del sitio (a veces sí, y el hallazgo real es solo que falta desde la navegación principal).
  3. Cruzar contra el resultado de un crawler que ejecuta JS real (no solo curl): si tampoco las encuentra pese a ejecutar JavaScript, confirma que el bloqueo es la interacción, no la falta de renderizado.
  4. Verificar en Search Console (URL Inspection) que Google reporta la URL como "Unknown"/nunca rastreada — no solo "Descubierta, sin indexar" (que implicaría que sí la encontró por algún camino).

Falso positivo a evitar: un primer chequeo de enlaces con una regexp poco delimitada (buscar "cualquier href" en todo el documento) puede capturar hasta el final del <body>, incluidos bloques <script> con JSON de hidratación que mencionan la URL como dato — sin que exista un <a href> real. Escopar la búsqueda al elemento HTML real (<nav>, <footer>) antes de concluir "sí hay enlace".

Fix correcto: el botón que abre/cierra el menú puede seguir siendo interactivo (UX sin cambios) — lo que debe cambiar es que cada item del menú exista como <a href="..."> real en el HTML servido, independiente de si el dropdown está abierto o cerrado visualmente. El toggle decide visibilidad (CSS/estado), no existencia en el DOM.


Soft-404 sistémico por rutas catch-all — dos variantes distintas

Frameworks con rutas dinámicas por segmento (ej. /[lang]/... en Next.js App Router) pueden interpretar cualquier segmento no reconocido como si fuera un idioma o parámetro válido, devolviendo contenido real con HTTP 200 en lugar de un 404 verdadero. Verificar ambas variantes por separado — no son el mismo bug:

Variante 1 — segmento inventado en nivel superior capturado como si fuera válido: una ruta de nivel superior con nombre arbitrario (/cualquier-cosa, /robots.php) cae en la ruta dinámica genérica y devuelve el contenido completo de la home con HTTP 200, incluido <html lang="cualquier-cosa">. Es un duplicado exacto de la home bajo cualquier URL inventada — riesgo de contenido duplicado a escala si algo (backlinks rotos, typos indexados, scrapers) genera tráfico hacia rutas inexistentes.

Variante 2 — catch-all interno con página de error real pero código HTTP incorrecto: una ruta con segmento válido pero sub-ruta inexistente (/es/producto-que-no-existe) cae en un catch-all interno ([...not-found]) que sí renderiza una página "404 - No encontrado" visualmente distinta a la home, pero sirviéndola con HTTP 200 en lugar de 404 real. Google clasifica esto como soft-404 en el informe de Cobertura — categoría distinta de "duplicado con la home" pero igual de dañina para el crawl budget.

Verificación: probar con curl varias rutas inventadas en ambos niveles (/xyz123, /es/xyz123) y comparar el código HTTP real (curl -o /dev/null -w "%{http_code}") contra el contenido servido. Cualquier combinación de "contenido real + HTTP 200" en una URL que debería ser 404 es soft-404, sin importar si el contenido visual es la home o una página de error genérica.

Fix: ambas rutas catch-all deben invocar el mecanismo nativo de 404 del framework (en Next.js App Router, notFound() de next/navigation; en Nuxt 3, throw createError({ statusCode: 404, statusMessage: '...' }) dentro de la página [...slug].vue o del server middleware correspondiente) para que el código de respuesta sea 404 real, no 200.

Causa específica en Netlify — la regla de fallback SPA es el origen más frecuente de esta variante, no una excepción: el patrón _redirects con /* /index.html 200 (o su equivalente en netlify.toml con [[redirects]] status = 200) es la configuración estándar recomendada para SPAs de una sola página en Netlify, y produce exactamente la Variante 1 de este hallazgo por diseño — cualquier ruta inventada sirve index.html con HTTP 200. Si el sitio usa un router del lado del cliente (React Router, Vue Router) sobre esa regla, la propia plataforma de rutas debe renderizar un componente 404 explícito y la respuesta debe forzarse a 404 real donde sea posible (Netlify Edge Functions o Functions pueden interceptar y reescribir el código de estado; el _redirects estático no puede condicionar el código HTTP al contenido). Si el sitio es SSR/SSG (Next.js, Nuxt) desplegado en Netlify, esta regla de fallback no debería existir — es un patrón exclusivo de SPA pura, su presencia en un proyecto SSR es en sí misma la causa raíz a corregir.

Antes de pedir el 404: comprobar qué más atiende esa ruta catch-all. En una auditoría real, la ruta /[lang]/[...not-found] que devolvía soft-404 también servía a los subdominios de aplicación (portal de clientes, portal ejecutivo) y a enlaces de cotización enviados a clientes. "No hay nada que el 404 pueda romper" resultó falso y solo salió al revisar en profundidad. Revisión mínima antes de recomendar el cambio:

  • Reunir URL de todas las fuentes: GSC 16 meses, landings y páginas vistas de GA4, Bing, crawl, sitemap, enlaces y recursos de todas las páginas, backlinks y archivos de raíz.
  • Probarlas en vivo salto a salto; en Vercel, la cabecera X-Matched-Path dice qué ruta del proyecto atendió cada URL.
  • Revisar los subdominios que comparten despliegue, los títulos de GA4 por ruta y qué sirve cada ruta sin sesión iniciada.
  • El entregable separa "Qué no debe cambiar" (rutas en operación), recomienda una opción (Desarrollo decide la implementación), lista resguardos y validaciones, y deja aparte, para consulta, las decisiones con valor en juego (p. ej. 301 de URL que tienen tráfico).
  • Cuantificar también las cifras infladas por la condición (páginas "vistas" que eran soft-404 en GA4, URL descubiertas en GSC, issues en auditorías) para que la caída tras el arreglo no se lea como pérdida.

Canonical y hreflang — sin plugin, todo vía metadata del framework

Sin Yoast/Rank Math generándolo automáticamente, cada página debe declarar su propio canonical y sus etiquetas hreflang recíprocas de forma explícita en el componente o capa de metadata del framework (en Next.js App Router, la función generateMetadata() de cada ruta; en Nuxt 3, el composable useSeoMeta() o useHead() dentro de <script setup>, típicamente combinado con definePageMeta() para datos estáticos de la ruta — o el módulo @nuxtjs/seo si el proyecto ya lo integra, en cuyo caso verificar su configuración en nuxt.config.ts antes de asumir que falta). Ver la skill canonical para las reglas generales (self-referencing, coherencia con sitemap) y hreflang para reciprocidad — lo específico de este stack es que no hay UI de plugin donde verificar la configuración: solo el HTML servido dice la verdad, sin importar el framework.

Patrón de bug frecuente en sitios multi-idioma sin canonical: una cadena de redirects de detección de idioma en la raíz (dominio.com → www.dominio.com → www.dominio.com/es) usando código 307 (temporal) en el primer salto (la normalización de host, que debería ser permanente) en lugar de reservar el 307 solo para el salto que sí depende del visitante (la detección de idioma en sí). Sin canonical que corrija la señal, Google puede terminar indexando la versión sin www/sin idioma como canonical real, en vez de la URL con idioma que sí tiene contenido. Confirmar con URL Inspection API sobre las 2-3 variantes de la home — "Duplicate, Google chose different canonical than user" es la señal directa de este patrón.

Metadata en streaming de Next.js 15.2+ — title, canonical y hreflang dentro del <body>. Desde Next.js 15.2, la metadata asíncrona se envía en streaming: title, description, robots, canonical y hreflang llegan dentro del <body> para todos los user agents salvo los que el framework considera bots limitados, y el <head> solo trae scripts. Google los ve tras renderizar (React los sube al <head>), pero las herramientas que leen el HTML servido los ignoran: Semrush informa "International SEO: Not implemented" y 0 hreflang aunque estén completos, y cualquier crawler o agente de IA sin JS ve la página sin title ni canonical. Comparar el mismo sitio con dos productos de la misma marca puede mostrar uno con la metadata en el <head> y otro en el <body>, según la versión y el uso de generateMetadata asíncrono. Diagnóstico: curl -s URL | grep -n -iE "<title|rel=\"canonical|hreflang" y mirar si las líneas quedan antes o después de <body. Solución para Desarrollo: configurar htmlLimitedBots en next.config para servir la metadata bloqueante en el <head> a los bots que interesan, o volver síncrona la metadata de las rutas clave.

Redirección de idioma por IP en el cliente: ver hreflang, Error 10. No se detecta sin navegador y sin simular varios países.


Cabeceras HTTP y redirects — configuración de proyecto, no de plugin

En ausencia de un panel de hosting con UI (tipo cPanel/plugin de cabeceras), las cabeceras de seguridad y los redirects a nivel de host se declaran en el archivo de configuración del framework/plataforma de despliegue:

  • Next.js/Vercel: next.config.js con la función headers()/redirects(), o vercel.json con sus propias claves headers/redirects (si ambos existen, vercel.json tiene precedencia sobre la config de Next.js — verificar cuál está realmente aplicando el valor observado en curl antes de indicarle a Desarrollo dónde tocar).
  • Nuxt/Vercel o Nuxt/Netlify: nitro.routeRules dentro de nuxt.config.ts (ej. routeRules: { '/**': { headers: { 'X-Frame-Options': 'DENY' } } }), que Nitro traduce automáticamente a la plataforma de destino sin necesidad de un archivo de config específico del hosting.
  • Netlify (cualquier framework): archivo _headers en la raíz del output (una ruta por línea, cabeceras indentadas debajo) o el bloque [[headers]] de netlify.toml; los redirects van en _redirects o en [[redirects]] de netlify.toml. Diferencia real frente a Vercel que vale la pena verificar siempre con curl: si una línea de _redirects no especifica código de estado explícito, Netlify aplica 301 por defecto — distinto del comportamiento de vercel.json, donde el campo "permanent" decide entre 307 (por defecto, false) y 308/301. Un redirect que "se ve igual" en el archivo de config puede estar sirviendo un código distinto según la plataforma; no asumir el código HTTP sin comprobarlo con curl -o /dev/null -w "%{http_code}".

El comportamiento esperado (qué código HTTP, qué valores de cabecera) es terreno SEO/seguridad verificable por curl; el código exacto de esa configuración es responsabilidad de Desarrollo — no prescribir el archivo de config completo en un hallazgo de auditoría, describir el resultado esperado y dejar el mecanismo a quien conoce el stack real.

Ver la skill cache-headers para cabeceras de cacheo y la ausente en este listado ssl-https/third-party-scripts para CSP y HSTS — la única diferencia real en sitios de este stack es dónde vive la configuración, no qué cabeceras hacen falta.


Performance — atributos que no hacen nada si se aplican mal

fetchpriority solo aplica a elementos que descargan un recurso (<img>, <link>, <script>, <iframe>). Es un error frecuente proponerlo sobre un elemento de texto (<h1>, <p>) cuando ese es el elemento LCP identificado por Lighthouse/PSI — el navegador lo ignora silenciosamente, sin error visible, dando una falsa sensación de que el hallazgo ya se resolvió. Si el LCP es texto, la causa del retraso de renderizado está en otro lado (CSS bloqueante de esa sección, carga de fuente web bloqueante, o hidratación del lado del cliente que retrasa la aparición del nodo) — investigar esa causa real, no el atributo.

CSS render-blocking: distinguir siempre CSS crítico (necesario para pintar el contenido above-the-fold) de CSS no crítico antes de recomendar "diferir el CSS" en bloque — diferir indiscriminadamente puede empeorar el LCP que se está intentando arreglar, o producir FOUC.

Polyfills de navegadores antiguos (Array.prototype.at/flat/flatMap, Object.fromEntries/hasOwn, etc.) detectados como peso muerto por Lighthouse: no recomendar eliminarlos sin que Desarrollo confirme primero el browserslist/navegadores que el negocio realmente necesita soportar — quitarlos a ciegas puede romper funcionalidad en navegadores todavía en uso por una parte real de la audiencia.


Checklist de auditoría — sitio JS-rendered sin CMS

CRÍTICO
[ ] ¿El HTML crudo (curl, sin JS) trae 0 enlaces/headings/schema pese a que el
    contenido sí se ve en el navegador? → verificar si es CSR normal o hidratación
    rota (cruzar con crawler JS, paso 2 de la sección de hidratación)
[ ] ¿Existen URLs reales solo alcanzables mediante interacción de UI (dropdown,
    tab, acordeón) sin ningún <a href> real en el HTML servido?
[ ] ¿Una ruta inventada de nivel superior devuelve HTTP 200 con contenido de home?
[ ] ¿Una sub-ruta inventada bajo un segmento válido devuelve HTTP 200 en vez de 404?
[ ] ¿El primer salto de una cadena de redirects de normalización de host es 307 en
    vez de 301/308 permanente?
[ ] (Netlify) ¿Existe una regla de fallback SPA (`_redirects` con `/* /index.html
    200`, o equivalente en `netlify.toml`) en un proyecto que en realidad es SSR/SSG,
    o que debería servir 404 real en rutas inexistentes?
[ ] (Vue/Nuxt) ¿El HTML crudo trae el shell vacío para todo el sitio (`ssr: false`,
    modo SPA) o solo faltan nav/headings puntuales por `<ClientOnly>`/guards
    `process.client`? — son hallazgos de alcance distinto, no reportarlos igual.

ALTO
[ ] ¿Cada ruta declara su propio canonical self-referencing vía metadata del
    framework, o depende de que el navegador nunca lo pida?
[ ] ¿hreflang recíproco entre versiones de idioma, generado por metadata o ausente
    por completo?
[ ] ¿Cabeceras de seguridad (CSP, X-Frame-Options, etc.) declaradas a nivel de
    proyecto, o completamente ausentes por no existir plugin que las agregue?

MEDIO
[ ] ¿fetchpriority aplicado a un elemento que no descarga recursos?
[ ] ¿CSS diferido en bloque sin distinguir crítico de no crítico?
[ ] ¿Polyfills de navegadores antiguos sin confirmar el browserslist real objetivo?

BAJO
[ ] ¿Imágenes sin width/height explícito (riesgo de CLS) en componentes JS?
[ ] ¿aria-label/aria-labelledby ausente en controles que un CMS con plugin de
    accesibilidad hubiera resuelto por defecto?

Referencias

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.