<link rel="stylesheet" href="/assets/fonts/inter/inter.css" />
All posts

Cómo diagnostiqué y resolví una crisis de indexación SEO en producción: caso práctico

Estado: borrador personal, pendiente de revisión antes de su publicación.

Esto no es una guía teórica: es el relato de una sesión real de depuración en este mismo sitio — un portfolio personal construido con Angular 21 (standalone components, SSR/prerendering, signals), backend NestJS + MongoDB en Railway y despliegue estático vía FileZilla en un hosting Plesk. El síntoma inicial era fácil de describir y frustrante de diagnosticar: cero páginas indexadas en Google, aunque el sitio llevaba meses online con contenido real. Lo que parecía un problema acabó revelando cinco, encadenados uno dentro de otro.

El stack y el contexto

  • Frontend: Angular 21, standalone components, desplegado como sitio estático prerenderizado (ningún servidor Node activo en producción — FileZilla sube el resultado del build a Plesk).
  • Backend: NestJS + MongoDB, desplegado en Railway, detrás de un rate limiter (@nestjs/throttler).
  • Contenido: un blog con artículos técnicos, traducido a 7 idiomas (italiano por defecto + inglés, albanés, español, portugués, francés, alemán).

Síntoma 1: el sitio no indexa nada

Primer diagnóstico, el más sencillo: comparar el HTML que recibe realmente un crawler para un artículo antiguo (que funciona) con uno nuevo (recién publicado).

$ curl -s https://gentsallaku.it/blog/articolo-vecchio | grep "<title>"
<title>Operatori RxJS: Guida Pratica con Casi d'Uso Reali | Gent Sallaku</title>

$ curl -s https://gentsallaku.it/blog/articolo-nuovo | grep "<title>"
<title>Gent Sallaku | Senior Front-End & API Developer</title>

El segundo comando es la prueba: el crawler recibe el título genérico de la home, no el del artículo. La causa: el sitio es estático, cada página se genera (prerenderiza) en el momento del build, y cada artículo nuevo se había insertado directamente en la base de datos sin volver a lanzar nunca el build y la subida. Google veía, literalmente, el último build disponible — que no contenía el contenido más reciente. No era un bug de código, sino un agujero en el proceso.

Síntoma 2: ni siquiera los artículos antiguos estaban todos rastreados

Segundo hallazgo: el sitemap.xml expuesto por el sitio solo listaba un puñado de URL estáticas, sin una sola entrada para los artículos individuales del blog. Incluso los posts prerenderizados correctamente no tenían forma de ser descubiertos de manera sistemática por Google, salvo mediante el rastreo orgánico de los enlaces internos — mucho más lento que un sitemap declarado explícitamente.

Síntoma 3: la verdadera base de datos de producción se llamaba "test"

Aquí el diagnóstico dejó de ser trivial. Tratando de entender por qué algunos contenidos publicados no aparecían nunca, revisé directamente la cadena de conexión de MongoDB usada en producción en Railway:

MONGODB_URI=mongodb://mongo:•••@mongodb-rhkn.railway.internal:27017

Ningún nombre de base de datos en la cadena. Cuando el driver de Mongo no encuentra un path explícito, recurre en silencio a una base de datos llamada "test". La base de datos "correcta", llamada portfolio_prod, existía de verdad en el mismo servidor — pero estaba abandonada, detenida en once posts de hacía meses. El sitio en producción, en realidad, servía contenido desde una base de datos que cualquiera podría haber supuesto razonablemente que estaba disponible para pruebas destructivas.

La corrección requirió tres pasos, en orden, para no perder datos reales por el camino:

  1. mongodump de la base de datos test (28 posts, miles de visitas, datos de consentimiento GDPR — todo contenido real)
  2. mongorestore --drop en portfolio_prod, en el mismo servidor
  3. Actualización de la variable MONGODB_URI en Railway para apuntar explícitamente a portfolio_prod, con el consiguiente redeploy automático

Verificado con una llamada directa a la API justo después del cambio, para confirmar cero pérdida de datos antes de dar el capítulo por cerrado.

Síntoma 4: las páginas prerenderizadas estaban siempre en inglés

Revisando el contenido real de las páginas estáticas generadas en el build, un detalle no encajaba: el texto de la home — bio, secciones, etiquetas — aparecía siempre en inglés, incluso para el único idioma que debía ser el predeterminado: el italiano.

export function resolveInitialLanguage(): Lang {
  if (typeof localStorage !== 'undefined') { /* ... */ }
  if (typeof navigator !== 'undefined') { /* ... */ }
  return 'en'; // ← eseguito SEMPRE durante il prerendering: Node non ha
               //    né localStorage né navigator
}

Durante el prerendering, el entorno es Node — no existe ningún navegador, así que ni localStorage ni navigator están disponibles, y la función recurría siempre al valor fijo en el código 'en'. Cada una de las páginas estáticas del sitio se había generado siempre en inglés, independientemente del idioma declarado.

Síntoma 5: hreflang que mentía a Google

El último hallazgo fue el más traicionero porque todo parecía correcto: cada página declaraba correctamente 7 etiquetas hreflang, una por idioma, según las buenas prácticas. El problema estaba en el formato de las URL declaradas:

<link rel="alternate" hreflang="en" href="https://gentsallaku.it/blog/articolo?lang=en" />
<link rel="alternate" hreflang="es" href="https://gentsallaku.it/blog/articolo?lang=es" />

Ninguna página de la aplicación leía nunca ese parámetro ?lang=. Cada variante declarada se resolvía, en la práctica, al mismo HTML exacto (el inglés mencionado arriba). Google recibía 7 declaraciones de contenido en idiomas distintos que llevaban todas a la misma página — una señal que, en el mejor de los casos, se ignoraba y, en el peor, reducía la confianza general en el sitio.

La solución estructural: URL realmente prefijadas por idioma

La corrección adecuada no era un parámetro que leer, sino un cambio de arquitectura: URL realmente distintas por idioma (/en/blog/articolo, /es/blog/articolo...), para que el prerenderer generara contenido realmente diferente para cada una, y no el mismo archivo repetido siete veces con otra etiqueta.

Primer intento, y por qué no funcionó

El primer diseño usaba un UrlMatcher personalizado para interceptar el prefijo de idioma sin duplicar todo el árbol de rutas. Correcto sobre el papel — pero el primer build real rompió todas las páginas existentes, no solo las nuevas:

✘ ERROR: The 'homepage' server route does not match any routes
  defined in the Angular routing configuration.

El prerenderer de Angular rechaza explícitamente el prerendering de cualquier ruta con un matcher personalizado y, más arriba, ni siquiera desciende a sus children durante la validación cruzada cliente/servidor. Una limitación poco documentada, descubierta solo al intentar el build de verdad — justo el tipo de riesgo que ningún análisis estático del código habría podido prever con certeza.

La solución: sustituir el matcher por una ruta path: ':lang' protegida por un guard canMatch — mismo comportamiento (ignora los segmentos que no son códigos de idioma válidos, p. ej. /dashboard), pero expresado como un segmento de path real, que el prerenderer sí sabe recorrer.

El efecto secundario oculto: el rate limiter

Con la nueva estructura funcionando, apareció un segundo problema solo después de extender el prerendering a todos los idiomas: cerca de una cuarta parte de los posts, de forma aparentemente aleatoria, se generaba como "artículo no encontrado" en lugar del contenido real.

La causa: generar 29 posts × 7 idiomas significa lanzar más de 200 llamadas a la API del blog en menos de un minuto, todas desde la misma IP de la máquina de build — superando el rate limit por defecto del backend (60 peticiones/60 segundos). Un primer intento de corregirlo con reintentos en el cliente empeoró la situación (páginas bloqueadas a mitad de carga, porque el prerenderer de Angular tiene un tiempo máximo de espera por ruta y los reintentos lo superaban). La corrección correcta estaba antes: subir el rate limit específicamente en los dos endpoints públicos y de solo lectura del blog, dejando intacta la protección del login y los formularios:

@Get('posts/:slug')
@Throttle({ default: { limit: 300, ttl: 60000 } }) // da 60 a 300 su questo endpoint
@ApiOperation({ summary: 'Get published post by slug (public)' })
findBySlug(@Param('slug') slug: string) {
  return this.blogService.findBySlug(slug);
}

Resultados medibles

MétricaAntesDespués
Páginas estáticas generadas en el build45273+
Entradas en sitemap.xml~40 (ningún post individual)242
Variantes hreflang que funcionan0 de 7 (mismo HTML para todas)7 de 7, contenido realmente distinto
Base de datos de producción"test" (implícita, no declarada)portfolio_prod (explícita)
Tasa de posts "no encontrado" en el build multilingüe~25%0%

Lecciones aprendidas

  • Comprueba siempre con curl, no a ojo: el título genérico de la home en lugar del del artículo solo se detectó comparando los bytes reales que recibe un crawler, no mirando el sitio en un navegador normal (que oculta estos problemas al ejecutar el JavaScript de todos modos).
  • Un nombre de base de datos implícito es un riesgo real, no un simple descuido estético: cualquiera que hubiera ejecutado un script "solo de prueba" contra una base de datos llamada literalmente test podría haber modificado datos de producción sin saberlo.
  • Los datos estructurados "correctos sobre el papel" hay que verificarlos de extremo a extremo: hreflang era sintácticamente perfecto, pero semánticamente falso, porque nadie había comprobado que las variantes declaradas llevaran de verdad a contenido diferente.
  • Las limitaciones no documentadas solo aparecen al intentar el build real: ninguna lectura del código ni de la documentación habría revelado que Angular rechaza el prerendering en rutas con matcher — solo el error de build lo hizo evidente.
  • Una corrección "defensiva" puede empeorar un síntoma en lugar de resolverlo: el reintento en el cliente parecía la solución obvia al rate limiting y, en cambio, introdujo un fallo silencioso peor (páginas bloqueadas) que el que debía resolver.

💬 Notas de los lectores

0 notas

Escribe una nota

Comparte tu opinión, una sugerencia o un cumplido

Últimas notas

Aún no hay notas. ¡Sé el primero en comentar!