Status: personal draft, awaiting review before publication.
This is not a theoretical guide: it is the account of a real debugging session on this very site — a personal portfolio built with Angular 21 (standalone components, SSR/prerendering, signals), a NestJS + MongoDB backend on Railway, and a static deploy via FileZilla to Plesk hosting. The initial symptom was simple to describe and frustrating to diagnose: zero pages indexed on Google, even though the site had been online for months with real content. What looked like one problem ended up revealing five, nested one inside the other.
The stack and the context
- Frontend: Angular 21, standalone components, deployed as a prerendered static site (no live Node server in production — FileZilla uploads the build output to Plesk).
- Backend: NestJS + MongoDB, deployed on Railway, behind a rate limiter (
@nestjs/throttler). - Content: a blog with technical articles, translated into 7 languages (Italian by default + English, Albanian, Spanish, Portuguese, French, German).
Symptom 1: the site doesn't get anything indexed
First diagnosis, the simplest one: compare the HTML a crawler actually receives for an old (working) article with a new (just published) one.
$ 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>
The second command is the proof: the crawler receives the generic homepage title, not the article's. The cause: the site is static, every page is generated (prerendered) at build time, and every new article had been inserted directly into the database without ever re-running the build and upload. Google was literally seeing the latest available build — which didn't contain the most recent content. Not a code bug, a gap in the process.
Symptom 2: not even the old articles were all tracked
Second finding: the sitemap.xml exposed by the site listed only a handful of static URLs, without a single entry for the individual blog articles. Even correctly prerendered posts had no way to be systematically discovered by Google, except through organic crawling of internal links — much slower than an explicitly declared sitemap.
Symptom 3: the real production database was called "test"
This is where the diagnosis stopped being trivial. Trying to understand why some published content never appeared, I checked the MongoDB connection string used in production on Railway directly:
MONGODB_URI=mongodb://mongo:•••@mongodb-rhkn.railway.internal:27017
No database name in the string. When the Mongo driver finds no explicit path, it silently falls back to a database called "test". The "correct" database, called portfolio_prod, really did exist on the same server — but it was abandoned, stuck at eleven months-old posts. The live site was actually serving content from a database that anyone could reasonably have assumed was available for destructive tests.
The fix took three steps, in order, so as not to lose real data in the meantime:
mongodumpof thetestdatabase (28 posts, thousands of page views, GDPR consent data — all real content)mongorestore --dropintoportfolio_prod, on the same server- Update of the
MONGODB_URIvariable on Railway to point explicitly toportfolio_prod, with an automatic redeploy as a result
Verified with a direct API call right after the cutover, to confirm zero data loss before considering the chapter closed.
Symptom 4: the prerendered pages were always in English
Checking the actual content of the static pages generated at build time, one detail was off: the homepage text — bio, sections, labels — always appeared in English, even for the one language that should have been the default: Italian.
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
}
During prerendering the environment is Node — there is no browser, so neither localStorage nor navigator is available, and the function always fell back to the hardcoded value 'en'. Every single static page of the site had always been generated in English, regardless of the declared language.
Symptom 5: hreflang lying to Google
The last finding was the most insidious because everything looked correct: every page correctly declared 7 hreflang tags, one per language, following best practices. The problem was in the format of the declared 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" />
No page in the application ever read that ?lang= parameter. Every single declared variant actually resolved to the exact same HTML (the English one mentioned above). Google received 7 declarations of content in different languages that all led to the same page — a signal that was ignored at best and, at worst, reduced overall trust in the site.
The structural solution: URLs truly prefixed by language
The right fix was not a parameter to read but an architectural change: truly distinct URLs per language (/en/blog/articolo, /es/blog/articolo...), so that the prerenderer would generate genuinely different content for each one, not the same file repeated seven times with a different label.
First attempt, and why it didn't work
The first design used a custom UrlMatcher to intercept the language prefix without duplicating the whole route tree. Correct on paper — but the first real build broke all existing pages, not just the new ones:
✘ ERROR: The 'homepage' server route does not match any routes
defined in the Angular routing configuration.
Angular's prerenderer explicitly refuses to prerender any route with a custom matcher, and further up it doesn't even descend into its children during client/server cross-validation. A limitation that isn't clearly documented, discovered only by actually attempting the build — exactly the kind of risk no static code analysis could have predicted with certainty.
The solution: replace the matcher with a path: ':lang' route protected by a canMatch guard — same behaviour (it ignores segments that aren't valid language codes, e.g. /dashboard), but expressed as a real path segment, which the prerenderer is actually able to traverse.
The hidden side effect: the rate limiter
With the new structure working, a second problem emerged only after extending prerendering to all languages: about a quarter of the posts, seemingly at random, were generated as "article not found" instead of the real content.
The cause: generating 29 posts × 7 languages means firing over 200 calls to the blog API in less than a minute, all from the same build machine IP — exceeding the backend's default rate limit (60 requests/60 seconds). A first attempt to fix it with client-side retries made things worse (pages stuck mid-load, because Angular's prerenderer has a maximum wait time per route and the retries exceeded it). The right fix was upstream: raise the rate limit specifically on the blog's two public, read-only endpoints, leaving the protection on login and forms unchanged:
@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);
}
Measurable results
| Metric | Before | After |
|---|---|---|
| Static pages generated at build | 45 | 273+ |
| Entries in sitemap.xml | ~40 (no individual posts) | 242 |
| Working hreflang variants | 0 of 7 (same HTML for all) | 7 of 7, genuinely distinct content |
| Production database | "test" (implicit, undeclared) | portfolio_prod (explicit) |
| Rate of "not found" posts in the multilingual build | ~25% | 0% |
Lessons learned
- Always verify with curl, not by eye: the generic homepage title in place of the article's was spotted only by comparing the actual bytes a crawler receives, not by looking at the site in a normal browser (which hides these problems by running the JavaScript anyway).
- An implicit database name is a real risk, not just a cosmetic oversight: anyone who had run a script "just for testing" against a database literally called
testcould have modified production data without knowing it. - Structured data that is "correct on paper" must be verified end-to-end: hreflang was syntactically perfect but semantically false, because nobody had checked that the declared variants actually led to different content.
- Undocumented limitations only show up when you attempt the real build: no reading of the code or the documentation would have revealed that Angular refuses to prerender routes with a
matcher— only the build error made it obvious. - A "defensive" fix can make a symptom worse instead of solving it: client-side retries looked like the obvious answer to rate limiting, and instead introduced a worse silent failure (stuck pages) than the one they were meant to fix.