SEO

Cómo hicimos indexable una SPA de React sin cambiar de framework

El caso técnico de romanketing.com: de un HTML vacío sin H1 ni enlaces a 29 rutas prerenderizadas con metadatos y JSON-LD propios. Con los comandos de verificación.

Una aplicación de una sola página que dibuja todo su contenido con JavaScript entrega un HTML vacío: un rastreador que no ejecuta scripts no ve el encabezado principal ni un solo enlace interno. Eso es exactamente lo que pasaba con romanketing.com, y es lo que arreglamos con prerenderizado estático sin cambiar de framework ni rehacer la aplicación.

Publicamos el caso completo porque es reproducible: cada verificación se hace con dos comandos y cualquiera puede correrlos sobre su propio sitio.

El diagnóstico

Una auditoría técnica reportó dos hallazgos sobre las trece páginas de entonces: h1_missing y no_inlinks. Los dos en todas las páginas, lo cual es la firma inconfundible del problema.

La comprobación es directa. Pide tu página como lo haría un rastreador y busca el encabezado:

curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://tudominio.com/ | grep -i "<h1"

Y cuenta los enlaces internos:

curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://tudominio.com/ | grep -c "<a .*href="

Si el primero no devuelve nada y el segundo devuelve cero o un número muy bajo, el contenido no está en la respuesta: está en el JavaScript que la construye después.

Google ejecuta JavaScript, con una demora y con un presupuesto de renderizado limitado. Pero no todos los rastreadores lo hacen, y varios de los que alimentan a los motores de IA no lo hacen en absoluto. Depender de eso es una apuesta innecesaria.

Por qué no elegimos renderizado condicional

La tentación es servir HTML completo sólo cuando el agente de usuario es un buscador. Es más simple de implementar y es una mala idea por dos razones: Google lo considera encubrimiento si el contenido difiere, y deja fuera a cualquier rastreador que no esté en tu lista.

Optamos por prerenderizado estático real: todo el mundo recibe el mismo HTML completo, y el JavaScript sólo hidrata lo que ya vino en la respuesta.

La implementación, en tres pasos

El proceso de compilación pasó a tener tres etapas:

  1. Compilación de cliente. Genera el paquete que corre en el navegador.
  2. Compilación de servidor. Genera un módulo que puede renderizar la aplicación en Node.
  3. Script de prerenderizado. Para cada ruta conocida, renderiza la aplicación a texto, y escribe un archivo HTML propio con el marcado ya dentro del contenedor de montaje.

El resultado es un archivo por ruta: /servicios/seo/index.html, /blog/index.html, y así. El hosting sirve el archivo estático que coincide con la URL antes de aplicar la regla comodín que devuelve la aplicación, así que cada URL recibe su propio HTML.

En el mismo paso se resuelven, por ruta, el título, la meta descripción, Open Graph, Twitter, el canonical y el JSON-LD de la página. Todo sale de un único módulo de configuración que también consume la aplicación en el navegador, de modo que el <head> que ve un rastreador y el que ve un usuario navegando no pueden desincronizarse.

Los tres problemas que aparecieron al hidratar

Para que React no descarte el HTML servido, el marcado del servidor y el primer render del cliente tienen que coincidir exactamente. Si no coinciden, React vuelve a renderizar todo en el navegador y se pierde justo lo que se quería ganar. Tres cosas rompían esa coincidencia:

La ruta. En Node no existe window.location, así que la ruta tiene que llegar como propiedad desde el punto de entrada del servidor y leerse de la barra de direcciones sólo en el navegador.

El ancho de pantalla. Un hook que leía matchMedia durante el render daba un valor en el navegador y otro en Node. Se corrigió arrancando siempre en el mismo valor y ajustando en un efecto de layout, antes del pintado.

El azar y el tiempo. Una simulación visual usaba Math.random() en el render, así que cada render producía marcado distinto. Se reemplazó por ruido determinista a partir de un índice.

Hay un cuarto caso que merece mención propia porque es de SEO puro: los contadores animados. Arrancaban en cero para que el HTML del servidor y el primer render coincidieran, y el efecto los animaba hasta el valor final. El costo es que el HTML servido decía "+0% de crecimiento" y "0,0x de leads". Cualquiera que llegara con la pestaña en segundo plano —o cualquier rastreador— leía que la agencia genera cero.

La solución fue invertirlo: el estado arranca en el valor final, que es lo que se prerenderiza, y un efecto de layout lo baja a cero sólo si el contador está fuera de la pantalla al montar. Como corre antes del pintado, no hay parpadeo; y como el primer render del cliente coincide con el del servidor, la hidratación no se rompe.

Un error que costó un rato: las sustituciones del <head>

Los reemplazos en la plantilla usaban String.replace con una cadena de reemplazo. En esa forma, $$ significa un $ literal y $& significa el match completo. Cualquier valor con signos de peso se corrompía en silencio: el priceRange: "$$" del JSON-LD salía como "$".

Se corrigió pasando una función de reemplazo en lugar de una cadena. Es un detalle de una línea que producía datos estructurados inválidos sin ningún error visible.

Las verificaciones que quedaron automatizadas

Cada publicación corre dos comprobaciones sobre el sitio ya compilado, sin ejecutar JavaScript:

  • Estructura: cada URL del sitemap tiene un h1 único, título y descripción únicos en todo el sitio, canonical correcto, enlaces internos reales y JSON-LD válido. Si aparece un título o una descripción repetidos, la compilación falla.
  • Hidratación: el sitio compilado se recorre en un navegador real a tres anchos distintos, y se falla si hay un error de hidratación, un error de consola, un desborde horizontal o un contenedor de montaje vacío.

El criterio de aceptación del proyecto era este, y se puede correr desde cualquier terminal:

curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://romanketing.com/servicios/seo/ | grep -i "<h1"
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://romanketing.com/servicios/seo/ | grep -c "<a .*href="

Encabezado presente y más de diez enlaces internos, en todas las URLs del sitemap.

Lo que quedó de herencia

El dominio venía de una instalación de WordPress abandonada, y la regla comodín del hosting respondía 200 a rutas viejas que ya no existían: perfiles de autor, feeds, comentarios y una papelera de servicios. Seguían indexadas.

Se resolvió con la cabecera X-Robots-Tag: noindex, follow sobre esas rutas, y con una decisión que conviene subrayar porque se hace mal muy seguido: no se bloquearon en robots.txt. Si Google no puede rastrear una URL, no puede leer la directiva que le pide sacarla del índice. Bloquear y pedir noindex a la vez es la forma más común de que una URL se quede indexada para siempre.

El orden que recomendamos

Si tenés que resolver algo parecido, el orden importa:

  1. Que el contenido llegue. Sin esto, nada de lo demás sirve.
  2. Que cada página diga qué es. Metadatos únicos, canonical y datos estructurados por ruta.
  3. Que la herencia no ensucie. Redirecciones y noindex sobre lo que ya no existe.
  4. Recién entonces, contenido y autoridad.

Invertir ese orden es publicar sobre una base que no puede sostener el trabajo. El detalle completo del proyecto está en la página del caso, y si el punto de partida de tu sitio es parecido, la consultoría SEO empieza exactamente por ahí.

¿Listo para transformar tu negocio?

Completa tus datos para recibir una auditoría personalizada y empezar a crecer estratégicamente.

Protección anti-spam

Configura `VITE_TURNSTILE_SITE_KEY` en el cliente y `TURNSTILE_SECRET_KEY` en el servidor antes de publicar.