Status: persönlicher Entwurf, vor der Veröffentlichung noch zu prüfen.
Dies ist kein theoretischer Leitfaden, sondern der Bericht einer echten Debugging-Session auf genau dieser Website – einem persönlichen Portfolio mit Angular 21 (Standalone Components, SSR/Prerendering, Signals), einem NestJS- + MongoDB-Backend auf Railway und einem statischen Deploy per FileZilla auf ein Plesk-Hosting. Das ursprüngliche Symptom war leicht zu beschreiben und frustrierend zu diagnostizieren: null indexierte Seiten bei Google, obwohl die Website seit Monaten mit echten Inhalten online war. Was wie ein Problem aussah, entpuppte sich am Ende als fünf, ineinander verschachtelte.
Der Stack und der Kontext
- Frontend: Angular 21, Standalone Components, als vorgerenderte statische Website ausgeliefert (kein laufender Node-Server in Produktion – FileZilla lädt das Build-Ergebnis auf Plesk hoch).
- Backend: NestJS + MongoDB, auf Railway deployt, hinter einem Rate Limiter (
@nestjs/throttler). - Inhalte: ein Blog mit technischen Artikeln, übersetzt in 7 Sprachen (Italienisch als Standard + Englisch, Albanisch, Spanisch, Portugiesisch, Französisch, Deutsch).
Symptom 1: Die Website wird überhaupt nicht indexiert
Erste und einfachste Diagnose: das HTML vergleichen, das ein Crawler für einen alten (funktionierenden) Artikel tatsächlich erhält, mit dem eines neuen (gerade veröffentlichten).
$ 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>
Der zweite Befehl ist der Beweis: Der Crawler erhält den generischen Titel der Startseite, nicht den des Artikels. Die Ursache: Die Website ist statisch, jede Seite wird beim Build generiert (vorgerendert), und jeder neue Artikel war direkt in die Datenbank eingefügt worden, ohne Build und Upload je neu anzustoßen. Google sah buchstäblich den letzten verfügbaren Build – der die neuesten Inhalte nicht enthielt. Kein Code-Bug, sondern eine Lücke im Prozess.
Symptom 2: Nicht einmal alle alten Artikel wurden erfasst
Zweite Erkenntnis: Die von der Website bereitgestellte sitemap.xml listete nur eine Handvoll statischer URLs, ohne einen einzigen Eintrag für die einzelnen Blogartikel. Selbst korrekt vorgerenderte Beiträge konnten von Google nicht systematisch entdeckt werden, außer über das organische Crawlen interner Links – viel langsamer als eine explizit deklarierte Sitemap.
Symptom 3: Die echte Produktionsdatenbank hieß „test“
Hier hörte die Diagnose auf, banal zu sein. Bei der Suche nach dem Grund, warum manche veröffentlichten Inhalte nie auftauchten, habe ich direkt den MongoDB-Connection-String geprüft, der in Produktion auf Railway verwendet wurde:
MONGODB_URI=mongodb://mongo:•••@mongodb-rhkn.railway.internal:27017
Kein Datenbankname im String. Findet der Mongo-Treiber keinen expliziten Pfad, weicht er stillschweigend auf eine Datenbank namens „test“ aus. Die „richtige“ Datenbank namens portfolio_prod existierte tatsächlich auf demselben Server – war aber verwaist und bei elf Monate alten Beiträgen stehen geblieben. Die Live-Website lieferte in Wahrheit Inhalte aus einer Datenbank, die jeder vernünftigerweise für destruktive Tests hätte freigegeben halten können.
Die Korrektur erforderte drei Schritte in dieser Reihenfolge, um zwischendurch keine echten Daten zu verlieren:
mongodumpder Datenbanktest(28 Beiträge, Tausende Seitenaufrufe, DSGVO-Einwilligungsdaten – alles echte Inhalte)mongorestore --dropinportfolio_prod, auf demselben Server- Aktualisierung der Variable
MONGODB_URIauf Railway, damit sie explizit aufportfolio_prodzeigt, mit anschließendem automatischem Redeploy
Unmittelbar nach der Umstellung mit einem direkten API-Aufruf geprüft, um null Datenverlust zu bestätigen, bevor das Kapitel als abgeschlossen galt.
Symptom 4: Die vorgerenderten Seiten waren immer auf Englisch
Beim Prüfen der tatsächlich im Build erzeugten statischen Seiten passte ein Detail nicht: Der Text der Startseite – Bio, Abschnitte, Beschriftungen – erschien immer auf Englisch, selbst für die eine Sprache, die eigentlich Standard sein sollte: Italienisch.
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
}
Beim Prerendering ist die Umgebung Node – es gibt keinen Browser, also sind weder localStorage noch navigator verfügbar, und die Funktion fiel immer auf den fest codierten Wert 'en' zurück. Jede einzelne statische Seite der Website war schon immer auf Englisch generiert worden, unabhängig von der deklarierten Sprache.
Symptom 5: hreflang, die Google belogen
Die letzte Erkenntnis war die heimtückischste, weil alles korrekt aussah: Jede Seite deklarierte ordnungsgemäß 7 hreflang-Tags, eines pro Sprache, nach Best Practices. Das Problem lag im Format der deklarierten URLs:
<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" />
Keine Seite der Anwendung las diesen Parameter ?lang= jemals aus. Jede deklarierte Variante führte faktisch zu exakt demselben HTML (dem oben erwähnten englischen). Google erhielt 7 Deklarationen von Inhalten in verschiedenen Sprachen, die alle auf dieselbe Seite führten – ein Signal, das bestenfalls ignoriert wurde und schlimmstenfalls das Vertrauen in die gesamte Website senkte.
Die strukturelle Lösung: URLs mit echtem Sprachpräfix
Die richtige Korrektur war kein auszulesender Parameter, sondern eine Architekturänderung: wirklich unterschiedliche URLs pro Sprache (/en/blog/articolo, /es/blog/articolo...), damit der Prerenderer für jede tatsächlich unterschiedliche Inhalte erzeugt – und nicht dieselbe Datei siebenmal mit anderem Etikett.
Erster Versuch, und warum er nicht funktionierte
Der erste Entwurf nutzte einen eigenen UrlMatcher, um das Sprachpräfix abzufangen, ohne den gesamten Routenbaum zu duplizieren. Auf dem Papier korrekt – doch der erste echte Build brach alle bestehenden Seiten, nicht nur die neuen:
✘ ERROR: The 'homepage' server route does not match any routes
defined in the Angular routing configuration.
Der Prerenderer von Angular verweigert ausdrücklich das Prerendering jeder Route mit eigenem matcher und steigt weiter oben bei der Client/Server-Kreuzvalidierung nicht einmal in deren children ab. Eine kaum dokumentierte Einschränkung, die erst beim tatsächlichen Build-Versuch zutage trat – genau die Art Risiko, die keine statische Codeanalyse sicher hätte vorhersagen können.
Die Lösung: den Matcher durch eine path: ':lang'-Route ersetzen, die von einem canMatch-Guard geschützt wird – gleiches Verhalten (Segmente, die keine gültigen Sprachcodes sind, z. B. /dashboard, werden ignoriert), aber als echtes Pfadsegment ausgedrückt, das der Prerenderer tatsächlich durchlaufen kann.
Der versteckte Nebeneffekt: der Rate Limiter
Mit der neuen, funktionierenden Struktur trat ein zweites Problem erst auf, nachdem das Prerendering auf alle Sprachen ausgeweitet war: Rund ein Viertel der Beiträge wurde scheinbar zufällig als „Artikel nicht gefunden“ statt mit dem echten Inhalt generiert.
Die Ursache: 29 Beiträge × 7 Sprachen zu generieren bedeutet über 200 Aufrufe der Blog-API in weniger als einer Minute, alle von derselben IP der Build-Maschine – mehr als das Standard-Rate-Limit des Backends (60 Anfragen/60 Sekunden). Ein erster Korrekturversuch mit clientseitigen Retries verschlimmerte die Lage (Seiten blieben mitten im Laden hängen, weil der Prerenderer von Angular eine maximale Wartezeit pro Route hat und die Retries diese überschritten). Die richtige Korrektur lag weiter vorn: das Rate Limit gezielt für die beiden öffentlichen, rein lesenden Endpunkte des Blogs anheben und den Schutz von Login und Formularen unverändert lassen:
@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);
}
Messbare Ergebnisse
| Kennzahl | Vorher | Nachher |
|---|---|---|
| Im Build erzeugte statische Seiten | 45 | 273+ |
| Einträge in der sitemap.xml | ~40 (keine einzelnen Beiträge) | 242 |
| Funktionierende hreflang-Varianten | 0 von 7 (gleiches HTML für alle) | 7 von 7, wirklich unterschiedliche Inhalte |
| Produktionsdatenbank | „test“ (implizit, nicht deklariert) | portfolio_prod (explizit) |
| Anteil „nicht gefunden“-Beiträge im mehrsprachigen Build | ~25% | 0% |
Gelernte Lektionen
- Immer mit curl prüfen, nicht nach Augenmaß: Der generische Startseitentitel anstelle des Artikeltitels fiel nur auf, weil ich die tatsächlich von einem Crawler empfangenen Bytes verglichen habe – nicht beim Betrachten der Website in einem normalen Browser (der diese Probleme verbirgt, weil er das JavaScript trotzdem ausführt).
- Ein impliziter Datenbankname ist ein echtes Risiko, nicht nur ein Schönheitsfehler: Wer ein Skript „nur zum Testen“ gegen eine Datenbank namens
testausgeführt hätte, hätte unwissentlich Produktionsdaten verändern können. - „Auf dem Papier korrekte“ strukturierte Daten müssen Ende-zu-Ende geprüft werden: Die hreflang waren syntaktisch perfekt, aber semantisch falsch, weil niemand geprüft hatte, ob die deklarierten Varianten tatsächlich zu unterschiedlichen Inhalten führten.
- Undokumentierte Einschränkungen zeigen sich erst beim echten Build: Kein Lesen von Code oder Dokumentation hätte offenbart, dass Angular das Prerendering von Routen mit
matcherverweigert – erst der Build-Fehler machte es sichtbar. - Eine „defensive“ Korrektur kann ein Symptom verschlimmern, statt es zu lösen: Clientseitige Retries wirkten wie die naheliegende Antwort auf das Rate Limiting und führten stattdessen einen schlimmeren stillen Fehler (hängende Seiten) ein als den, den sie beheben sollten.