This is Part 2 of my SEO case study. Part 1 covered the technical foundation: 9 fixes, PageSpeed 58→87, and the Astro stack setup.
In Part 1, I documented the baseline of rafaelroot.com: zero indexation, zero impressions, zero clicks. Astro SSG, strict technical SEO, mobile PageSpeed from 58 to 87.
Now for the growth phase. This article covers weeks 3 through 6: building authority, deploying to production, and reaching position 3 on Google's first page.
📊 TL;DR — Weeks 3 to 6
| Metric | Start | Week 6 |
|---|---|---|
| Indexed pages | 0/16 | 16/16 (100%) |
| Primary query position | 100+ | #3 (page 1) |
| Weekly impressions | 0 | 847 |
| CTR (main query) | — | 12.4% |
| Backlinks | 0 | 7 high-quality |
| Lighthouse scores | 87/99/99/99 | 100/100/100/100 |
⚡ 10-Day Indexation Checkpoint
Ten days after submitting the sitemap, Google indexed all 16 URLs. Complete coverage.
🎯 CHECKPOINT — Week 2 (03/14/2026)
- Indexed Pages: 16/16
- Impressions: 23 | Clicks: 2 | Position: 47.3
Why so fast?
- Clean sitemap — 16 URLs, zero 404s, no redirect chains
-
Semantic HTML — Hierarchical headings + valid
JSON-LDschemas - ~50ms TTFB — Googlebot parses static files instantly
🏗️ Production Deploy: Nginx Tuned for SEO
Brotli + Gzip compression
Brotli compresses ~8-9% better than gzip for HTML. Automatic gzip fallback for older clients:
brotli on;
brotli_comp_level 6;
brotli_static on;
brotli_min_length 256;
brotli_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript image/svg+xml font/woff2;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;
Measured results:
| Encoding | Size | Reduction |
|---|---|---|
| Uncompressed | 18,408 bytes | — |
| Gzip | 5,372 bytes | -70.8% |
| Brotli | 4,897 bytes | -73.4% |
💡 475 bytes less than gzip per request. On a 3G connection, that's the difference between passing or failing the LCP Core Web Vital.
HTTPS + HTTP/2 + Security headers
Full server block with Let's Encrypt SSL, HSTS preload, CSP, and all 6 security headers. Two permanent 301 redirects: HTTP→HTTPS and www→non-www (prevents duplicate content).
Layered cache strategy
| Type | Cache | Why |
|---|---|---|
| CSS, JS, fonts, images | 1 year, immutable
|
Hash in filename = never changes |
| HTML | 1 day, must-revalidate
|
Content may update |
| XML (sitemap) | 1 hour | Crawlers need fresh data |
Returning visitors download zero assets — only HTML is revalidated.
Production TTFB
Homepage: 94ms | Blog post: 73ms | Protocol: HTTP/2 | Encoding: Brotli
📈 GA4 Dashboard for SEO
Rankings mean nothing if users bounce. I configured GA4 with custom events:
// Scroll depth tracking — zero dependencies
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
gtag('event', 'scroll_depth', {
section: entry.target.id,
percent: entry.target.dataset.depth
});
}
});
}, { threshold: 0.5 });
document.querySelectorAll('section[data-depth]').forEach(s => observer.observe(s));
The SEO dashboard correlates Search Console (impressions, CTR, position) with GA4 (scroll depth, time-on-page, bounce rate).
🔗 Link Building: Authority Footholds
No guest post spam. Instead, I built contextual presence on platforms Google already trusts:
| # | Platform | Type | DA | Context |
|---|---|---|---|---|
| 1 | dev.to | Original article | 61 | Full case study with canonical_url
|
| 2 | GitHub | Profile + README | 96 | Bio + pinned repo with contextual link |
| 3 | Stack Overflow | Profile | 82 | Website field |
| 4 | Profile + article | 98 | Featured section + native post | |
| 5 | npm | Published package | 75 | Homepage field |
| 6 | Twitter/X | Profile + thread | 94 | Bio and tweet with link |
🔥 DEV.TO canonical hack: Using
canonical_urlin dev.to frontmatter transfers DA 61 authority to your blog without triggering duplicate content penalties. This article does exactly that.
The key insight: each link has context — it's not a naked URL in an empty bio.
🌍 Multilingual Content: The i18n Multiplier
5 languages (pt-BR, pt-PT, en, es, ru) — each article natively written, not machine-translated.
Why this matters:
- Google detects auto-translated content as low quality
- Each version ranks independently for queries in that language
-
hreflangtags mathematically link all versions
<link rel="alternate" hreflang="en"
href="https://rafaelroot.com/en/blog/seo-case-study-google-ranking-part-2/" />
<link rel="alternate" hreflang="es"
href="https://rafaelroot.com/es/blog/caso-estudio-seo-de-cero-a-google-parte-2/" />
<link rel="alternate" hreflang="x-default"
href="https://rafaelroot.com/blog/case-study-seo-do-zero-ao-google-parte-2/" />
📊 Results: 6 Weeks of Data
| Week | Impressions | Clicks | CTR | Avg. Position |
|---|---|---|---|---|
| 1 | 0 | 0 | — | — |
| 2 | 23 | 2 | 8.7% | 47.3 |
| 3 | 89 | 7 | 7.9% | 28.1 |
| 4 | 234 | 21 | 9.0% | 14.7 |
| 5 | 512 | 48 | 9.4% | 7.2 |
| 6 | 847 | 105 | 12.4% | 3.8 |
Top queries
| Query | Position | CTR |
|---|---|---|
| rafael cavalcanti da silva | #3 | 14.2% |
| rafaelroot | #1 | 28.6% |
| rafael cavalcanti developer | #5 | 8.9% |
SERP comparison — "rafael cavalcanti da silva"
| Position | Baseline | Week 6 |
|---|---|---|
| 1 | jusbrasil.com.br | jusbrasil.com.br |
| 2 | br.linkedin.com | br.linkedin.com |
| 3 | g1.globo.com | rafaelroot.com ✅ |
Separating technical vs. content impact
Technical fixes (weeks 1-2): complete indexation in 10 days, PageSpeed 87, zero Search Console errors.
Content + authority (weeks 3-6): impressions 0→847, ranking 100+→#3, CTR above market average.
The technical foundation let Google index quickly. But it was content and backlinks that moved the ranking.
💡 Lesson Learned: Don't Shorten Your Brand Name
I tested using the shorter "Rafael Cavalcanti" in titles. The logic: it's a substring, so coverage should be additive.
It wasn't. Within days, I lost ranking for the full name query. Google re-evaluated the page's primary entity and the diluted signal hurt more than the broader match helped.
Fix: Reverted all <title>, meta descriptions, og:site_name back to "Rafael Cavalcanti da Silva". The short form stays only in alternateName in structured data.
Takeaway: For personal brand SEO, consistency > coverage. If you rank for "firstname lastname suffix," don't dilute it by removing the suffix.
🚀 Post-Launch Optimizations (Updates 2-4)
These updates happened after the initial 6-week period and are fully documented in the canonical article.
Lighthouse: 100/100/100/100
Three targeted fixes to reach perfect scores:
| Issue | Before | After | Fix |
|---|---|---|---|
| LCP render delay | 920ms | 0 | Replaced JS font injection with CSS media="print" onload pattern |
| Missing og:image:alt | SEO 99 | 100 | Added dynamic og:image:alt + twitter:image:alt on all pages |
| CSS critical chain | 922ms | 0 |
build.inlineStylesheets: 'always' — zero external CSS |
| Forced reflow | 39ms | 0 | Batched getBoundingClientRect() reads before DOM writes |
Sitemap: from basic to fully optimized
| Feature | Before | After |
|---|---|---|
<lastmod> |
❌ | ✅ All 35 URLs |
<changefreq> |
❌ | ✅ Per-page-type |
<priority> |
❌ | ✅ Hierarchy (1.0→0.5) |
| Blog hreflang | ❌ | ✅ Cross-language links (5 locales) |
| Redirect URLs | Leaking ❌ | Filtered ✅ |
<image:image> |
❌ | ✅ 15 image entries |
The trickiest part: @astrojs/sitemap doesn't support blog posts with different slugs per locale. Solution: a blogTranslations map in serialize() that links all 5 versions:
const blogTranslations = {
'seo-case-study-part-1': {
'pt-BR': '/blog/case-study-seo-do-zero-ao-google-parte-1/',
'en': '/en/blog/seo-case-study-google-ranking-part-1/',
'es': '/es/blog/caso-estudio-seo-de-cero-a-google-parte-1/',
}
};
Image SEO + sitemap indexing
- Renamed
image.png→rafael-cavalcanti-da-silva-fullstack-developer.png - Created a custom Astro integration (
sitemapImageInjector) that injects<image:image>tags into sitemap XML post-build - 15 image entries enabling Google Image Search discovery
Custom 404 page + i18n coverage
Multilingual 404 page with contextual navigation. Added 55+ translation keys and the missing Russian "Trajetória" page.
📋 Full Checklist — Weeks 3-6
- ✅ 16/16 URLs indexed (100% coverage)
- ✅ GA4 with scroll depth + time-on-page tracking
- ✅ 7 high-quality backlinks built
- ✅ dev.to article with canonical link
- ✅ 5 languages, natively written
- ✅ hreflang validated across all versions
- ✅ Nginx: Brotli + HTTP/2 + HSTS + 6 security headers
- ✅ TTFB: 73-94ms in production
- ✅ Lighthouse: 100/100/100/100
- ✅ Sitemap: 35 URLs + priorities + image indexing
- ✅ "rafael cavalcanti da silva" → position 3
- ✅ "rafaelroot" → position 1
- ✅ CTR 12.4% (above market average)
What's Next (Part 3)
- A/B titles and meta descriptions for CTR optimization
- Real Core Web Vitals field data (CrUX) vs. lab (Lighthouse)
- Cannibalization analysis across multilingual versions
- Final goal: position 1
Community Feedback
The Part 1 article on dev.to generated valuable feedback:
@apogeewatcher suggested separating results into categories (indexation, ranking, CTR, on-page performance) instead of attributing everything to a single change. That suggestion directly influenced the "Separating technical vs. content impact" section above.
@apex_stack shared experience running a 100k+ page Astro site across 12 languages, validating the translationKey pattern and warning about crawl budget as a "silent variable."
@kritika_barod confirmed the pattern: Google crawled thousands of pages but didn't index them until authority signals improved.
Full technical details with Nginx configs, schema code, and all 4 updates: rafaelroot.com/en/blog/seo-case-study-google-ranking-part-2/
Found this useful? Drop a 🦄 and follow for Part 3 — or check out rafaelroot.com to see everything in action.
Top comments (0)