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

Why my Angular blog isn't indexed by Google (and how i fixed it)

Introduction: When Google Simply Ignores Your Blog

A few weeks ago I became aware of an annoying problem: the articles published on this same blog were not appearing on Google. They weren't badly positioned, they didn't get little traffic — they just weren't indexed. Opening Google Search Console, the situation was clear: out of 32 URLs sent in the sitemap, only 4 were indexed (home, blog listing, projects, contacts). Zero articles.

What follows is the actual path I took to diagnose and resolve the problem, from the first technical suspicion to the hidden cause that no one ever checks: duplicate content. If you run a technical blog, especially on an Angular application with server-side rendering, you'll probably recognize at least one of these problems.

First suspicion: pages rendered on the client side instead of the server side

The first check in these cases is always the same: what does Googlebot really see when it visits a page? On an Angular app with SSR, if server-side rendering doesn't work as expected, the crawler receives only the empty HTML shell — no specific , no meta description, no actual textual content, all populated via JavaScript after Angular bootstraps.</p> <p>In my case the cause was in angular.json: the project was still using the legacy boolean flag "prerender": true instead of the newer "outputMode": "server". With the Boolean flag, Angular prerenders only static routes and silently ignores getPrerenderParams() — the function that expands dynamic routes like /blog/:slug by fetching all slugs published by the backend. Result: all static pages (home, projects, contacts) were pre-rendered correctly, but every single blog post remained a pure client-side page.</p> <pre><code>// angular.json — prima (sbagliato) "prerender": true // angular.json — dopo (corretto) "outputMode": "server"</code></pre> <p>With outputMode: "server", Angular actually calls getPrerenderParams() for each dynamic route configured in app.routes.server.ts, which in my case queries GET /blog/posts on the backend and generates a static page for each slug posted. After the fix, the build finally produced each article as real HTML, with the title, meta description and JSON-LD Article ready in the first byte.</p> <h2>Apache's hidden bug: 301 redirects on every prerendered page</h2> <p>Having fixed the prerendering, a check with a simple curl -I (not a browser, which follows redirects transparently and hides the problem) revealed a second flaw: every prerendered page responded with 301 Moved Permanently towards the same URL with the trailing slash added.</p> <p>The cause is Apache's default behavior, mod_dir: when a request matches a real directory on disk (and that's exactly what prerendering creates: /blog/article-name/index.html), Apache automatically redirects by adding the trailing slash, before the mod_rewrite rules in the .htaccess can intervene. The content behind the redirect was correct, but the canonical URL declared on each page (without a trailing slash, consistent with the sitemap) never returned a direct 200 — the crawlers always received a URL different from the one marked as canonical.</p> <pre><code><IfModule mod_dir.c> DirectorySlash Off </IfModule> RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME}/index.html -f RewriteRule ^(.*)$ $1/index.html [L]</code></pre> <p>By turning off automatic slashing and adding an explicit rule that serves index.html directly on the unslashed URL, every prerendered page started responding 200 on the exact canonical URL.</p> <h2>Static sitemap vs sitemap generated from real content</h2> <p>A third problem, more banal but equally blocking: the sitemap only contained the listing / blog page, not the individual articles. Each new post published remained invisible to Google until discovered via internal links — a slow and by no means guaranteed process.</p> <p>The solution was to transform the sitemap generation script from a static list of routes to a function that queries the backend for each published post:</p> <pre><code>async function fetchBlogRoutes() { const res = await fetch(`${API_BASE_URL}/blog/posts?page=1&limit=50`); const { data, meta } = await res.json(); const posts = [...data]; for (let page = 2; page <= meta.totalPages; page++) { const r = await fetch(`${API_BASE_URL}/blog/posts?page=${page}&limit=50`); const j = await r.json(); posts.push(...j.data); } return posts.map(p => ({ loc: `/blog/${p.slug}`, lastmod: p.updatedAt })); }</code></pre> <p>The same script now also runs as GitHub Action, every night and with every push, so every new article published automatically enters the sitemap without manual intervention.</p> <h2>The most insidious cause: duplicate content</h2> <p>After all these technical fixes, the situation was improved but not resolved: most articles remained in the "Detected, currently not indexed" state — Google knew the URLs from the sitemap, but had not yet considered them relevant enough to index. Analyzing the list article by article, I found two posts published within 28 seconds of each other, on the exact same topic, with almost indistinguishable titles and almost overlapping content — probably an accidental double posting from the editor panel.</p> <p>Duplicate content does not block the indexing of a single page: it is a signal of quality evaluated at the level of the entire domain. On a new site, with very little external authority, a couple of duplicate posts can be enough to make Google more wary of even completely original content on the same domain. I deleted the version with fewer views and regenerated the sitemap: it is probably the single intervention with the greatest impact on the overall indexing of the site.</p> <h2>How to actually read the Search Console "Page Indexing" report</h2> <p>The Google Search Console report groups unindexed pages by reason, and each label requires a different action:</p> <ul> <li>Detected, currently not indexed: Google knows the URL (usually from the sitemap) but hasn't crawled it yet. It's a matter of time and crawl budget, not a mistake to fix.</li> <li>Crawled, currently not indexed: Google visited the page but chose not to index it, often due to content deemed to be of low value or duplicate.</li> <li>Alternate page with appropriate canonical tag: normal for translated versions of the same content that correctly point to the main version — not an error.</li> <li>Redirect error: Pay attention to the date of the last scan. If the bug was fixed after that date, the report simply shows outdated data: just check with curl -I that the URL responds 200 today, then use "Validate fix" to force a new check.</li> </ul> <h2>Practical checklist</h2> <ul> <li>Check with curl -I (not with the browser) that each page responds directly to the canonical URL, without hidden redirects.</li> <li>Check that the content received by a crawler is identical to that of a real browser: curl -A "Googlebot" must return complete HTML, not an empty shell waiting for JavaScript.</li> <li>Generate the sitemap dynamically from published content, not from a static list of routes.</li> <li>Look for nearly identical titles or topics across your posts – duplicate content is a domain problem, not a single page one.</li> <li>Use "Request Indexing" in Search Console on newer posts instead of waiting for the natural crawl, which can take weeks on a young domain.</li> </ul> <h2>Realistic timelines</h2> <p>On a new domain, with few signs of external authority, it is normal for the first indexing to take from a few days (with manual request) to several weeks (via natural crawl). What really matters is eliminating technical and content quality obstacles: once removed, it's just a matter of giving Google time to review and trust the domain.</p> </article>

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!