DEV Community

The 4 static-mirror drift classes hiding in your Blazor WASM SEO (and the audit script that finds them)

The setup, if you don’t already have it

The .razor file is the source of truth. Blazor renders it after hydration. The static HTML mirror is a snapshot of what the page should look like on first paint, for the crawler + for the ~1-2 seconds users wait for WASM to boot. In practice this means every content-page razor has a paired wwwroot/<same-path>/index.html, and every razor edit is supposed to also update the mirror. Supposed to.

The problem: razor edits happen in normal development flow, mirror edits require you to remember. Over 6 months and ~40 pages, drift accumulates. And it’s invisible to dotnet build because the mirror is just static HTML - the compiler doesn’t check it.

Drift class #1 - missing BreadcrumbList JSON-LD

Discovery: grep -L '"BreadcrumbList"' wwwroot/blog/*/index.html returned 7 files.

BreadcrumbList JSON-LD is what makes Google render “Home › Blog › Article Title” as a visible breadcrumb in the search snippet. Without it in the mirror, the crawler sees no breadcrumb at first index. My <SeoSchema> component in each razor emits it correctly at hydration - but the crawler reads the mirror before hydration. That schema was invisible to Google for 7 blog posts.

Fix: 7 additive <script type="application/ld+json"> blocks injected before </head>, matching the exact 3-level pattern (Home → Blog → Article Title) that my razors emit at runtime. Pattern was copy-paste from an already-working mirror.

Drift class #2 - missing FAQPage JSON-LD despite razor having FaqItems

Discovery: grep -L '"FAQPage"' wwwroot/blog/*/index.html returned 16 files. But when I cross-checked which of those razors had a FaqItems=@(new List<SeoSchema.FaqItem> { ... }) param on the <SeoSchema> component, 6 of the 16 did.

Those 6 razors are actively emitting FAQPage schema at hydration. Their mirrors have zero FAQPage schema. Users get the schema after WASM boots; Google’s initial crawl doesn’t. FAQPage is one of Google’s most-visible SERP features (the expandable Q&A dropdown), and I was leaving CTR on the floor across 6 blogs for months.

Fix: I wrote a Node script that regex-extracted the new("Question", "Answer") tuples from each razor’s FaqItems= block, JSON.stringify()’d them into the SIMPLE FAQPage schema format (matching what my already-working mirrors used), and injected before </head>. 38 Q&A pairs baked in across 6 files. All validated via JSON.parse post-write.

The 10 razors without FaqItems= are a separate content-authoring problem - they need FAQ Q&A written from scratch + reviewer signoff on YMYL claims. Different scope, deferred.

Drift class #3 - asymmetric hreflang cluster

Discovery: my sitemap.xml declares 4 hreflang alternates for my old-vs-new-regime cluster:

<xhtml:link rel="alternate" hreflang="en-IN" href=".../blog/old-vs-new-regime/" />
<xhtml:link rel="alternate" hreflang="hi-IN" href=".../blog/hi/old-vs-new-regime/" />
<xhtml:link rel="alternate" hreflang="gu-IN" href=".../blog/gu/old-vs-new-regime/" />
<xhtml:link rel="alternate" hreflang="x-default" href=".../blog/old-vs-new-regime/" />

But the page HTML on 2 of the 3 language variants (English + Hindi mirrors) had only 3 <link rel="alternate"> tags - missing gu-IN. The Gujarati mirror correctly had all 4.

This is an asymmetric hreflang cluster: the sitemap says GU exists, English + Hindi pages don’t reference it back. Google’s algorithm for hreflang requires bidirectional references to trust the cluster.

Impact: the Gujarati variant may lose SERP visibility in gu-IN searches (no reciprocal reference from English/Hindi means weaker language-cluster signal). Small volume for my site but a real signal loss.

Fix: 2 additive <link rel="alternate" hreflang="gu-IN"> tags. All 3 mirrors now show 4-alternate symmetric cluster.

Drift class #4 - meta keywords copy-paste template

Discovery: I noticed during the FAQPage injection that 5 blog mirrors shared identical <meta name="keywords"> content - the generic homepage keywords like “income tax calculator india fy 2026-27, free tax calculator india no signup...” - even though the blogs were about wildly different topics (NPS 80CCD(1B), PPF vs ELSS, RSU tax, home loan interest, Form 10E). Cross-checked: the razors had topical keywords. The mirrors had the generic homepage boilerplate.

Classic mirror-drift on a low-priority attribute nobody remembered to sync. Google mostly ignores <meta name="keywords"> since 2009. But:

  • Bing still uses it (per Bing Webmaster docs)
  • Yandex uses it
  • Some AI content classifiers use it
  • Human quality-raters see it - 5 topically-different blog pages sharing identical keywords looks like template-duplicate spam

Fix: extracted each razor’s keywords content via regex, wrote it back into the corresponding mirror. 5 files, 5 topical keyword strings replacing 5 identical-homepage-boilerplate strings.

The audit script

This is the one-liner that started the whole weekend:

# Which blog mirrors are missing BreadcrumbList?
grep -L '"BreadcrumbList"' wwwroot/blog/*/index.html

# Which are missing FAQPage?
grep -L '"FAQPage"' wwwroot/blog/*/index.html

# Which are missing Article?
grep -L '"@type":"Article"' wwwroot/blog/*/index.html

# Cross-check razors: which razors HAVE FaqItems but mirrors DON'T have FAQPage?
# (this is where the "shippable parity fix" list comes from - the razor already
# has the data, the mirror just needs to be synced)

If you have static mirrors for a Blazor WASM SPA and haven’t grep’d them for schema completeness in a while, run these three grep commands. I’d bet money you find at least one drift class.

Why this happens

Static mirrors are a duplication. The DRY-in-code violation is what makes them work - the mirror is deliberately a copy, not generated, because generating them at build time (via prerendering) requires either running Blazor in server mode (defeats the point of static WASM) or shipping a whole SSG pipeline (heavier than the drift risk).

My repo made the pragmatic call: hand-maintain the mirror, add a lint that catches divergence for a growing list of known bad patterns. The lint I have (scripts/lint-mirror-integrity.ps1) catches YMYL content drift - like a mirror saying “80CCD(1B) is allowed in the New Regime” when the razor correctly says it’s disallowed (that class of drift once shipped a real integrity issue to production for weeks). But schema-presence isn’t in the lint. Neither is meta-keywords drift, or hreflang symmetry. My Saturday audit was manual grep-driven. I’ll add these classes to the lint next.

Prescription

If you have this architecture:

  • grep -L schema audits are cheap enough to run monthly. Add them as CI checks if you can.
  • Cross-check razor source-of-truth against mirror - the FaqItems= vs <script>"FAQPage"</script> mismatch was the highest-signal finding.
  • Sitemap ↔ page HTML hreflang symmetry - if you have multilingual variants, verify both sides declare each other.
  • Meta keywords is deprecated but not zero-signal - copy-paste template duplication reads as low-effort content to human reviewers, even if Google’s algo ignores the tag itself.

Static mirrors solve a real problem (crawler + first-paint UX). They introduce a real risk (drift you don’t see). The audit script + lint list keeps that risk bounded.

One more thing

My audit found all 4 classes on a Saturday morning. Shipping the fixes across 13 mirror files took a Node script + a couple of hours. The impact: 6 blog pages became eligible for Google FAQ rich results, 7 became eligible for breadcrumb SERP renders, 2 got proper Article JSON-LD, 5 got topical keywords instead of homepage template.

If you have static mirrors and haven’t audited them recently, do it. The scripts above are literally the whole thing.

I build client-side Indian tax calculators at smarttaxcalc.in - Blazor WASM, no backend, all client-side. This weekend’s shipping arc: IsTrimmable=false is not enough was article #7 in this series.

Comments

No comments yet. Start the discussion.