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

Si e diagnostikova dhe e zgjidha një krizë indeksimi SEO në prodhim: rast studimi

Statusi: draft personal, në pritje të rishikimit para publikimit.

Ky nuk është një udhëzues teorik: është tregimi i një seance reale debugging-u në pikërisht këtë sajt — një portfolio personal i ndërtuar me Angular 21 (standalone components, SSR/prerendering, signals), backend NestJS + MongoDB në Railway, deploy statik me FileZilla në hosting Plesk. Simptoma fillestare ishte e thjeshtë për t'u përshkruar dhe frustruese për t'u diagnostikuar: zero faqe të indeksuara në Google, edhe pse sajti ishte online prej muajsh me përmbajtje reale. Ajo që dukej si një problem përfundoi duke zbuluar pesë, të futur njëri brenda tjetrit.

Stack-u dhe konteksti

  • Frontend: Angular 21, standalone components, deploy si sajt statik i prerenderuar (asnjë server Node aktiv në prodhim — FileZilla ngarkon output-in e build-it në Plesk).
  • Backend: NestJS + MongoDB, i vendosur në Railway, pas një rate limiter-i (@nestjs/throttler).
  • Përmbajtja: një blog me artikuj teknikë, i përkthyer në 7 gjuhë (italisht si parazgjedhje + anglisht, shqip, spanjisht, portugalisht, frëngjisht, gjermanisht).

Simptoma 1: sajti nuk indekson asgjë

Diagnoza e parë, më e thjeshta: të krahasosh HTML-në që merr realisht një crawler për një artikull të vjetër (që funksionon) me një të ri (sapo publikuar).

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

Komanda e dytë është prova: crawler-i merr titullin e përgjithshëm të homepage-it, jo atë të artikullit. Shkaku: sajti është statik, çdo faqe gjenerohet (prerenderohet) në momentin e build-it, dhe çdo artikull i ri ishte futur direkt në bazën e të dhënave pa e rinisur kurrë build-in dhe ngarkimin. Google shihte, fjalë për fjalë, build-in e fundit të disponueshëm — që nuk përmbante përmbajtjet më të reja. Jo një bug kodi, por një vrimë në proces.

Simptoma 2: edhe artikujt e vjetër nuk ishin të gjithë të gjurmuar

Zbulimi i dytë: sitemap.xml i ekspozuar nga sajti listonte vetëm një grusht URL-sh statike, pa asnjë hyrje për artikujt e veçantë të blogut. Edhe postimet e prerenderuara saktë nuk kishin mënyrë të zbuloheshin sistematikisht nga Google, përveçse përmes crawling-ut organik të lidhjeve të brendshme — shumë më i ngadaltë se një sitemap i deklaruar qartë.

Simptoma 3: baza e vërtetë e të dhënave e prodhimit quhej "test"

Këtu diagnoza pushoi së qeni banale. Duke u përpjekur të kuptoja pse disa përmbajtje të publikuara nuk shfaqeshin kurrë, kontrollova direkt stringun e lidhjes MongoDB të përdorur në prodhim në Railway:

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

Asnjë emër baze të dhënash në string. Kur driver-i Mongo nuk gjen një path të qartë, bie në heshtje te një bazë të dhënash me emrin "test". Baza "e saktë", me emrin portfolio_prod, ekzistonte vërtet në të njëjtin server — por ishte e braktisur, e ngrirë me njëmbëdhjetë postime të vjetra prej muajsh. Sajti live, në fakt, po shërbente përmbajtje nga një bazë të dhënash që kushdo mund të mendonte me arsye se ishte e lirë për teste shkatërruese.

Korrigjimi kërkoi tre hapa, me radhë, për të mos humbur të dhëna reale ndërkohë:

  1. mongodump e bazës së të dhënave test (28 postime, mijëra page view, të dhëna pëlqimi GDPR — të gjitha përmbajtje reale)
  2. mongorestore --drop brenda portfolio_prod, në të njëjtin server
  3. Përditësimi i variablës MONGODB_URI në Railway për të treguar qartë drejt portfolio_prod, me redeploy automatik si pasojë

Verifikuar me një thirrje direkte në API menjëherë pas kalimit, për të konfirmuar zero humbje të dhënash para se ta konsideroja kapitullin të mbyllur.

Simptoma 4: faqet e prerenderuara ishin gjithmonë në anglisht

Duke kontrolluar përmbajtjen reale të faqeve statike të gjeneruara në build, një detaj nuk shkonte: teksti i homepage-it — bio, seksione, etiketa — shfaqej gjithmonë në anglisht, edhe për gjuhën e vetme që duhej të ishte parazgjedhja: italishten.

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
}

Gjatë prerendering-ut, mjedisi është Node — nuk ekziston asnjë shfletues, ndaj as localStorage as navigator nuk janë të disponueshëm, dhe funksioni binte gjithmonë te vlera e koduar fort 'en'. Çdo faqe statike e sajtit, gjithmonë, ishte gjeneruar në anglisht, pavarësisht gjuhës së deklaruar.

Simptoma 5: hreflang që i gënjente Google-it

Zbulimi i fundit ishte më tinëzari sepse gjithçka dukej e saktë: çdo faqe deklaronte saktë 7 tag-e hreflang, një për gjuhë, sipas praktikave më të mira. Problemi ishte në formatin e URL-ve të deklaruara:

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

Asnjë faqe e aplikacionit nuk e lexonte kurrë atë parametër ?lang=. Çdo variant i deklaruar zgjidhej, në fakt, te e njëjta HTML (ajo në anglisht e përmendur më sipër). Google merrte 7 deklarata përmbajtjeje në gjuhë të ndryshme që të gjitha çonin te e njëjta faqe — një sinjal që, në rastin më të mirë, injorohej, dhe në më të keqin ulte besimin e përgjithshëm te sajti.

Zgjidhja strukturore: URL me prefiks real gjuhe

Fix-i i duhur nuk ishte një parametër për t'u lexuar, por një ndryshim arkitekture: URL realisht të ndryshme për çdo gjuhë (/en/blog/articolo, /es/blog/articolo...), në mënyrë që prerenderer-i të gjeneronte përmbajtje realisht të ndryshme për secilën, jo të njëjtin skedar të përsëritur shtatë herë me një etiketë tjetër.

Përpjekja e parë, dhe pse nuk funksionoi

Dizajni i parë përdorte një UrlMatcher të personalizuar për të kapur prefiksin e gjuhës pa dyfishuar gjithë pemën e rrugëve. Në letër i saktë — por build-i i parë real prishi të gjitha faqet ekzistuese, jo vetëm të rejat:

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

Prerenderer-i i Angular refuzon shprehimisht prerendering-un në çdo rrugë me një matcher të personalizuar, dhe më lart as nuk zbret në children e saj gjatë validimit të kryqëzuar klient/server. Një kufizim jo i dokumentuar qartë, i zbuluar vetëm duke provuar realisht build-in — pikërisht lloji i rrezikut që asnjë analizë statike e kodit nuk mund ta parashikonte me siguri.

Zgjidhja: zëvendësimi i matcher-it me një rrugë path: ':lang' të mbrojtur nga një guard canMatch — e njëjta sjellje (injoron segmentet që nuk janë kode gjuhe të vlefshme, p.sh. /dashboard), por e shprehur si segment real path-i, që prerenderer-i di ta përshkojë.

Efekti anësor i fshehur: rate limiter-i

Me strukturën e re që funksiononte, një problem i dytë doli vetëm pasi zgjerova prerendering-un në të gjitha gjuhët: rreth një e katërta e postimeve, në mënyrë në dukje rastësore, gjenerohej si "artikull nuk u gjet" në vend të përmbajtjes reale.

Shkaku: gjenerimi i 29 postimeve × 7 gjuhë do të thotë më shumë se 200 thirrje te API-ja e blogut në më pak se një minutë, të gjitha nga e njëjta IP e makinës së build-it — duke kapërcyer rate limit-in parazgjedhur të backend-it (60 kërkesa/60 sekonda). Një përpjekje e parë për ta korrigjuar me retry në anën e klientit e përkeqësoi situatën (faqe të bllokuara në mes të ngarkimit, sepse prerenderer-i i Angular ka një kohë maksimale pritjeje për çdo rrugë dhe retry-t e kapërcenin). Korrigjimi i duhur ishte më lart: rritja e rate limit-it posaçërisht në dy endpoint-et publike dhe vetëm për lexim të blogut, duke e lënë të pandryshuar mbrojtjen në login dhe formularë:

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

Rezultate të matshme

MetrikaParaPas
Faqe statike të gjeneruara në build45273+
Hyrje në sitemap.xml~40 (asnjë postim individual)242
Variante hreflang që funksionojnë0 nga 7 (e njëjta HTML për të gjitha)7 nga 7, përmbajtje realisht e ndryshme
Baza e të dhënave e prodhimit"test" (e nënkuptuar, e padeklaruar)portfolio_prod (e qartë)
Përqindja e postimeve "nuk u gjet" në build-in shumëgjuhësh~25%0%

Mësimet e nxjerra

  • Verifiko gjithmonë me curl, jo me sy: titulli i përgjithshëm i homepage-it në vend të atij të artikullit u identifikua vetëm duke krahasuar bajtet reale që merr një crawler, jo duke parë sajtin në një shfletues normal (që i fsheh këto probleme sepse e ekzekuton gjithsesi JavaScript-in).
  • Një emër i nënkuptuar i bazës së të dhënave është rrezik real, jo thjesht një pakujdesi estetike: kushdo që do të kishte ekzekutuar një skript "vetëm për test" kundër një baze të dhënash që quhej fjalë për fjalë test mund të kishte ndryshuar të dhëna prodhimi pa e ditur.
  • Të dhënat e strukturuara "të sakta në letër" duhen verifikuar nga fillimi në fund: hreflang ishte sintaksorisht i përsosur, por semantikisht i rremë, sepse askush nuk kishte verifikuar që variantet e deklaruara të çonin vërtet te përmbajtje të ndryshme.
  • Kufizimet e padokumentuara zbulohen vetëm duke provuar build-in real: asnjë lexim i kodit apo i dokumentacionit nuk do të kishte zbuluar që Angular refuzon prerendering-un në rrugë me matcher — vetëm gabimi i build-it e bëri të qartë.
  • Një fix "mbrojtës" mund ta përkeqësojë një simptomë në vend që ta zgjidhë: retry-i në anën e klientit dukej zgjidhja e qartë për rate limiting, dhe në vend të kësaj solli një dështim të heshtur më të keq (faqe të bllokuara) se ai që duhej të zgjidhte.

💬 Shënime nga lexuesit

0 shënime

Shkruaj një shënim

Ndaj mendimin tënd, një sugjerim ose një kompliment

Shënimet e fundit

Ende asnjë shënim. Bëhu i pari që komenton!