<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Come ho diagnosticato e risolto una crisi di indicizzazione SEO in produzione: Case Study

Stato: bozza personale, in attesa di revisione prima della pubblicazione.

Questo non è una guida teorica: è il racconto di una sessione di debugging reale su questo stesso sito — un portfolio personale costruito con Angular 21 (standalone components, SSR/prerendering, signals), backend NestJS + MongoDB su Railway, deploy statico via FileZilla su hosting Plesk. Il sintomo iniziale era semplice da descrivere e frustrante da diagnosticare: zero pagine indicizzate su Google, nonostante il sito fosse online da mesi con contenuti reali. Quello che sembrava un problema ha finito per rivelarne cinque, concatenati uno dentro l'altro.

Lo stack e il contesto

  • Frontend: Angular 21, standalone components, deploy come sito statico prerenderizzato (nessun Node server live in produzione — FileZilla carica l'output di build su Plesk).
  • Backend: NestJS + MongoDB, deployato su Railway, dietro un rate limiter (@nestjs/throttler).
  • Contenuto: un blog con articoli tecnici, tradotto in 7 lingue (italiano di default + inglese, albanese, spagnolo, portoghese, francese, tedesco).

Sintomo 1: il sito non indicizza nulla

Prima diagnosi, la più semplice: confrontare l'HTML che un crawler riceve davvero per un articolo vecchio (funzionante) contro uno nuovo (appena pubblicato).

$ 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>

Il secondo comando è la prova: il crawler riceve il titolo generico della homepage, non quello dell'articolo. La causa: il sito è statico, ogni pagina viene generata (prerenderizzata) al momento della build, e ogni nuovo articolo era stato inserito direttamente nel database, senza mai far ripartire build e upload. Google vedeva, letteralmente, l'ultima build disponibile — che non conteneva i contenuti più recenti. Non un bug di codice, un buco nel processo.

Sintomo 2: anche i vecchi articoli non sono tutti tracciati

Seconda scoperta: la sitemap.xml esposta dal sito elencava solo una manciata di URL statici, senza una singola voce per i singoli articoli del blog. Anche i post correttamente prerenderizzati non avevano modo di essere scoperti sistematicamente da Google, se non tramite crawling organico dei link interni — molto più lento di una sitemap dichiarata esplicitamente.

Sintomo 3: il vero database di produzione si chiamava "test"

Qui la diagnosi ha smesso di essere banale. Cercando di capire perché alcuni contenuti pubblicati non comparissero mai, ho controllato direttamente la stringa di connessione MongoDB usata in produzione su Railway:

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

Nessun nome di database nella stringa. Quando il driver Mongo non trova un path esplicito, ripiega silenziosamente su un database chiamato "test". Il database "corretto", chiamato portfolio_prod, esisteva davvero sullo stesso server — ma era abbandonato, fermo a undici post vecchi di mesi. Il sito live, in realtà, stava servendo contenuto da un database che chiunque avrebbe potuto ragionevolmente pensare fosse disponibile per test distruttivi.

La correzione ha richiesto tre passaggi, in ordine, per non perdere dati reali nel frattempo:

  1. mongodump del database test (28 post, migliaia di page view, dati di consenso GDPR — tutto contenuto reale)
  2. mongorestore --drop dentro portfolio_prod, sullo stesso server
  3. Aggiornamento della variabile MONGODB_URI su Railway per puntare esplicitamente a portfolio_prod, con conseguente redeploy automatico

Verificato con una chiamata diretta all'API subito dopo il cutover, per confermare zero perdita di dati prima di considerare chiuso il capitolo.

Sintomo 4: le pagine prerenderizzate erano sempre in inglese

Controllando il contenuto effettivo delle pagine statiche generate in build, un dettaglio stonava: il testo della homepage — bio, sezioni, etichette — appariva sempre in inglese, anche per l'unica lingua che avrebbe dovuto essere il default: l'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 il prerendering, l'ambiente è Node — non esiste alcun browser, quindi né localStorage né navigator sono disponibili, e la funzione ripiegava sempre sul valore hardcoded 'en'. Ogni singola pagina statica del sito, da sempre, era stata generata in inglese, indipendentemente dalla lingua dichiarata.

Sintomo 5: hreflang che mentiva a Google

L'ultima scoperta è stata la più insidiosa perché sembrava tutto corretto: ogni pagina dichiarava correttamente 7 tag hreflang, uno per lingua, secondo le best practice. Il problema era nel formato degli URL dichiarati:

<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" />

Nessuna pagina dell'applicazione leggeva mai quel parametro ?lang=. Ogni singola variante dichiarata risolveva, di fatto, allo stesso identico HTML (quello inglese di cui sopra). Google riceveva 7 dichiarazioni di contenuto in lingue diverse che portavano tutte alla stessa pagina — un segnale che, nella migliore delle ipotesi, veniva ignorato, e nella peggiore riduceva la fiducia complessiva nel sito.

La soluzione strutturale: URL realmente prefissati per lingua

Il fix corretto non era un parametro da leggere, ma un cambio di architettura: URL realmente distinti per lingua (/en/blog/articolo, /es/blog/articolo...), in modo che il prerenderer generasse contenuto realmente diverso per ciascuno, non lo stesso file ripetuto sette volte con un'etichetta diversa.

Primo tentativo, e perché non ha funzionato

Il primo design usava un UrlMatcher personalizzato per intercettare il prefisso lingua senza duplicare l'intero albero di route. Sulla carta corretto — ma la prima build reale ha rotto tutte le pagine esistenti, non solo quelle nuove:

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

Il prerenderer di Angular rifiuta esplicitamente il prerendering su qualsiasi route con un matcher personalizzato, e più a monte non scende nemmeno nei suoi children durante la validazione incrociata client/server. Una limitazione non documentata in modo evidente, scoperta solo tentando davvero la build — esattamente il tipo di rischio che nessuna analisi statica del codice avrebbe potuto prevedere con certezza.

La soluzione: sostituire il matcher con una route path: ':lang' protetta da una guardia canMatch — stesso comportamento (ignora segmenti che non sono codici lingua validi, es. /dashboard), ma espresso come segmento di path reale, che il prerenderer sa effettivamente attraversare.

L'effetto collaterale nascosto: il rate limiter

Con la nuova struttura funzionante, un secondo problema è emerso solo dopo aver esteso il prerendering a tutte le lingue: circa un quarto dei post, in modo apparentemente casuale, veniva generato come "articolo non trovato" invece del contenuto reale.

La causa: generare 29 post × 7 lingue significa emettere oltre 200 chiamate all'API del blog in meno di un minuto, tutte dalla stessa IP della macchina di build — superando il rate limit di default del backend (60 richieste/60 secondi). Un primo tentativo di correzione con retry lato client ha peggiorato la situazione (pagine bloccate a metà caricamento, perché il prerenderer di Angular ha un tempo massimo di attesa per ogni route, e i retry lo superavano). La correzione giusta era a monte: alzare il rate limit specificamente sui due endpoint pubblici e in sola lettura del blog, lasciando invariata la protezione su login e form:

@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);
}

Risultati misurabili

MetricaPrimaDopo
Pagine statiche generate in build45273+
Voci nella sitemap.xml~40 (nessun post individuale)242
Varianti hreflang funzionanti0 su 7 (stesso HTML per tutte)7 su 7, contenuto realmente distinto
Database di produzione"test" (implicito, non dichiarato)portfolio_prod (esplicito)
Tasso di post "non trovato" in build multilingua~25%0%

Lezioni imparate

  • Verifica sempre con curl, non a occhio: il titolo generico della homepage al posto di quello dell'articolo è stato individuato solo confrontando byte reali ricevuti da un crawler, non guardando il sito in un browser normale (che nasconde questi problemi eseguendo comunque il JavaScript).
  • Un nome di database implicito è un rischio reale, non solo una svista estetica: chiunque avesse eseguito uno script "solo per test" contro un database letteralmente chiamato test avrebbe potuto modificare dati di produzione senza saperlo.
  • I dati strutturati "corretti sulla carta" vanno verificati end-to-end: hreflang era sintatticamente perfetto, ma semanticamente falso, perché nessuno aveva verificato che le varianti dichiarate portassero davvero a contenuto diverso.
  • Le limitazioni non documentate si scoprono solo tentando la build reale: nessuna lettura del codice o della documentazione avrebbe rivelato che Angular rifiuta il prerendering su route con matcher — solo l'errore di build l'ha reso evidente.
  • Un fix "difensivo" può peggiorare un sintomo invece di risolverlo: il retry lato client sembrava la soluzione ovvia al rate limiting, e ha invece introdotto un fallimento silenzioso peggiore (pagine bloccate) di quello che doveva risolvere.

💬 Notas dos leitores

0 notas

Escreva uma nota

Partilhe a sua opinião, uma sugestão ou um elogio

Notas recentes

Ainda não há notas. Seja o primeiro a comentar!