<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chetan Sanghani</title>
    <description>The latest articles on DEV Community by Chetan Sanghani (@chetansanghani).</description>
    <link>https://dev.to/chetansanghani</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012473%2Fd0f9b093-7be7-4801-be6e-1e88a0198457.JPG</url>
      <title>DEV Community: Chetan Sanghani</title>
      <link>https://dev.to/chetansanghani</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chetansanghani"/>
    <language>en</language>
    <item>
      <title>I built a lint that caught 3 tax-rule lies my manual sweep missed — a pattern-based safety net for hand-maintained SEO</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Wed, 22 Jul 2026 17:13:29 +0000</pubDate>
      <link>https://dev.to/chetansanghani/i-built-a-lint-that-caught-3-tax-rule-lies-my-manual-sweep-missed-a-pattern-based-safety-net-for-3n9</link>
      <guid>https://dev.to/chetansanghani/i-built-a-lint-that-caught-3-tax-rule-lies-my-manual-sweep-missed-a-pattern-based-safety-net-for-3n9</guid>
      <description>&lt;p&gt;Three Sundays ago I found seven pages on my site independently asserting something factually wrong about Indian tax law. The &lt;code&gt;.razor&lt;/code&gt; sources were all correct. The pre-rendered static HTML mirrors — which is what Google actually indexes — had drifted.&lt;/p&gt;

&lt;p&gt;I fixed all seven by hand. I congratulated myself. I moved on.&lt;/p&gt;

&lt;p&gt;Then this Wednesday I built a lint to catch the pattern automatically. It surfaced three more identical lies on three completely different pages. My manual sweep had missed them.&lt;/p&gt;

&lt;p&gt;Here's what I built, why regex alone doesn't work, and the two-phase pattern that does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup — why drift happens
&lt;/h2&gt;

&lt;p&gt;SmartTaxCalc.in is a Blazor WebAssembly SPA deployed to Cloudflare Pages. Every blog post has two representations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Pages/Blog/*.razor&lt;/code&gt;&lt;/strong&gt; — the source of truth. Compiled into the WASM bundle. Rendered client-side after Blazor boots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wwwroot/blog/&amp;lt;slug&amp;gt;/index.html&lt;/code&gt;&lt;/strong&gt; — a static HTML mirror. What Cloudflare serves at page load. What Google indexes. What users see for the first 1-2 seconds before Blazor hydrates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mirrors are &lt;strong&gt;hand-maintained&lt;/strong&gt;. No build step regenerates them from the razors. So when I fix a fact in a razor, nothing propagates it to the mirror. The mirror keeps the old, wrong content.&lt;/p&gt;

&lt;p&gt;I already had one lint guarding one part of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/lint-title-parity.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Ensures the razor's &amp;lt;PageTitle&amp;gt; matches the mirror's &amp;lt;title&amp;gt;.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Runs in CI on every push touching razor/mirror paths.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches SEO titles going out of sync. It does not catch anything below the title tag. Every paragraph of body content is on trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sunday crisis
&lt;/h2&gt;

&lt;p&gt;Running through an editorial integrity audit, I found the same false claim in seven different blog mirrors:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Section 80CCD(1B) NPS is now allowed in both regimes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For readers not in India: this is wrong. Section 80CCD(1B) is a ₹50,000 individual NPS deduction that is Old-Tax-Regime-only. The New Regime disallows it. It matters — a taxpayer following that advice would over-claim and face a Section 143(1) intimation.&lt;/p&gt;

&lt;p&gt;The razors said the correct thing everywhere: "80CCD(1B) is Old Regime only." Every mirror I inspected asserted the opposite. Seven of them. Independently. No shared component.&lt;/p&gt;

&lt;p&gt;Root cause: some earlier session (months ago, before I was on the project) fixed the razors after a Budget-2025 misreading. Never touched the mirrors. Title-parity was 94/0 clean the whole time because titles never changed.&lt;/p&gt;

&lt;p&gt;I fixed all seven mirrors in one commit and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lint I built this Wednesday
&lt;/h2&gt;

&lt;p&gt;Three days later, still uneasy that I might have missed one, I built a lint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naïve attempt&lt;/strong&gt;: search for the deduction name near "New Regime":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'80CCD\(1B\).*(?:allowed|available)(?:.*)?new\s+regime'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ran it against 100 mirrors. &lt;strong&gt;Seventeen violations.&lt;/strong&gt; All false positives. Every mirror flagged said something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Section 80CCD(1B) is &lt;strong&gt;disallowed&lt;/strong&gt; under the New Regime."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The word &lt;code&gt;allowed&lt;/code&gt; is a substring of &lt;code&gt;disallowed&lt;/code&gt;. Word boundaries help a little (&lt;code&gt;\ballowed\b&lt;/code&gt;), but not enough — real sentences frame this rule in many ways: "not allowed", "not permitted", "only in Old Regime", "cannot claim", "forfeit", etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-phase pattern that works
&lt;/h2&gt;

&lt;p&gt;The insight: a &lt;strong&gt;positive assertion&lt;/strong&gt; and a &lt;strong&gt;negation&lt;/strong&gt; can share the same lexical proximity. What distinguishes them is context. So don't try to write one regex that captures both.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt;: Find suspicious co-occurrences (the deduction name near "New Regime"). Cast a wide net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt;: For each hit, examine the surrounding ~400 characters. If any &lt;strong&gt;safe-indicator word&lt;/strong&gt; appears (&lt;code&gt;disallow&lt;/code&gt;, &lt;code&gt;not allow&lt;/code&gt;, &lt;code&gt;Old Regime only&lt;/code&gt;, &lt;code&gt;without touching&lt;/code&gt;, &lt;code&gt;mistake&lt;/code&gt;, &lt;code&gt;cannot&lt;/code&gt;, etc.), the sentence is likely correcting the claim, not asserting it. Skip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the core of the current lint (PowerShell 7):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$forbiddenClaims&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'80CCD(1B) claimed to work in New Regime'&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'(?:80CCD\(1B\)[^\r\n]{0,120}(?:new\s+regime|new\s+tax\s+regime))'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="s1"&gt;'|(?:(?:new\s+regime|new\s+tax\s+regime)[^\r\n]{0,120}80CCD\(1B\))'&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Fact&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Section 80CCD(1B) is Old Regime only. Only 80CCD(2) survives the New Regime."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c"&gt;# ... more rules&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nv"&gt;$safeIndicators&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bdisallow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bnot\s+allow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bdoes\s+not\s+(?:allow|permit|apply)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bold\s+regime\s+only'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bwithout\s+(?:ever\s+)?touching'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bmistake'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bbelieving\s+80CCD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c"&gt;# ~20 more&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Test-HasSafeIndicator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$safeIndicators&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-imatch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$mirrorFiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# ...skip &amp;lt;head&amp;gt;...&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$forbiddenClaims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$windowEnd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$windowEnd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

            &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Test-HasSafeIndicator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;continue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

            &lt;/span&gt;&lt;span class="c"&gt;# Real violation. Report.&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$violations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FullName&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;Match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two hundred lines total, including all 4 rules and 30-odd safe indicators.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened when I ran it
&lt;/h2&gt;

&lt;p&gt;First run against the "clean" tree (post my Sunday fixes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mirrors inspected: 100
Violations: 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirteen? I'd sworn I fixed all of them.&lt;/p&gt;

&lt;p&gt;Nine were false positives — sentences correctly stating the rule, plus a few worked-example patterns like &lt;em&gt;"Under the Old Regime with HRA claim you would take home X"&lt;/em&gt; (correct, but "HRA" and "New Regime" appeared within 100 chars of each other elsewhere in the paragraph). I extended the safe-indicators list to cover those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four were real.&lt;/strong&gt; Different pages, all mine, all live:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/tax-saving-new-regime/&lt;/code&gt;, line 166 (intro paragraph):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"For FY 2026-27, a salaried New Regime taxpayer can still claim Rs 75,000 standard deduction, &lt;strong&gt;Rs 50,000 NPS under 80CCD(1B)&lt;/strong&gt;, and an uncapped employer NPS contribution under 80CCD(2)."&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/nps-tax-benefits/&lt;/code&gt;, line 383 (Bottom Line section):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"…NPS Tier-1 is one of the highest-leverage tax-saving instruments available — &lt;strong&gt;especially under the New Regime, where it's the only meaningful retirement deduction left. Contributing the full Rs 50,000 under 80CCD(1B)&lt;/strong&gt; saves…"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/tax-saving-tips/&lt;/code&gt;, line 166 (intro):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"…and &lt;strong&gt;Section 80CCD(1B) NPS is now allowed in both regimes&lt;/strong&gt;. This guide cuts through the noise…"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/blog/section-80c-investments/&lt;/code&gt; — the same class of drift, slightly different phrasing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd manually fixed the &lt;em&gt;other&lt;/em&gt; body sections of exactly these files on Sunday. The intros and bottom-lines were on completely different lines. I hadn't caught them because I was grep-searching for the specific phrasings I'd already found. The lint's proximity pattern didn't care about phrasing — it just noticed the semantic co-occurrence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same commit that added the lint also fixed the four newly-surfaced lies.&lt;/strong&gt; Verification loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pwsh &lt;span class="nt"&gt;-File&lt;/span&gt; scripts/lint-mirror-integrity.ps1
&lt;span class="go"&gt;Mirrors inspected: 100
Violations: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this generalizes
&lt;/h2&gt;

&lt;p&gt;I'm not the only one running a Blazor/Astro/Next.js site with a JS-hydration layer over a static SEO mirror. The exact tech stack varies. The pattern is universal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two representations of the same content&lt;/li&gt;
&lt;li&gt;Only one is authoritative (the source)&lt;/li&gt;
&lt;li&gt;Only the other is what search engines actually see&lt;/li&gt;
&lt;li&gt;Sync is manual or "should be automatic but isn't"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lint doesn't care about the specific technology. It cares about the semantic co-occurrence in whatever HTML the crawler will see.&lt;/p&gt;

&lt;p&gt;Adapt it to your own YMYL (Your-Money-Your-Life) content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Financial&lt;/strong&gt;: interest rates, tax brackets, deduction limits, loan terms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medical&lt;/strong&gt;: dosage claims, treatment eligibility, side-effect statements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal&lt;/strong&gt;: statute-of-limitations, jurisdiction rules, filing deadlines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coding tutorials&lt;/strong&gt;: version-specific API claims ("this works in React 17" while React 19 shipped last month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each rule is one entry: a co-occurrence pattern and a fact. Safe indicators are shared across all rules. Both are easy to grow — the day I discover another drift class, I add one entry to the &lt;code&gt;$forbiddenClaims&lt;/code&gt; array.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lint does not do
&lt;/h2&gt;

&lt;p&gt;Deliberate non-goals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not regenerate mirrors from razors.&lt;/strong&gt; That's a build-step problem, orders of magnitude bigger. The lint just guards specific known-false claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not use an LLM.&lt;/strong&gt; LLM-based fact-checking would be more general but expensive and non-deterministic. Regex + safe-indicator words is deterministic, runs in under a second on 100 files, and costs nothing in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't check the razor sources.&lt;/strong&gt; Those are the source of truth. If a razor is wrong, the CA reviewer catches it. The lint's scope is: mirrors must not be worse than razors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wire it into CI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/lint-title-parity.yml&lt;/code&gt; picked up the new job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mirror-integrity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Mirror body-content YMYL integrity&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run lint-mirror-integrity&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh -File ./scripts/lint-mirror-integrity.ps1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Path-filtered to razor/mirror/lint-script paths so it doesn't fire on unrelated pushes. Fails the check on any violation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Three things I didn't expect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;My "manual sweep" felt thorough because I'd fixed the specific phrasings I'd found.&lt;/strong&gt; I hadn't fixed the pattern. The lint caught what I'd missed because it looked for the pattern, not the phrasing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Safe-indicator words are the harder half.&lt;/strong&gt; The forbidden-pattern list was five minutes of work. The safe-indicator list has been iterated three times as new false-positives appeared. Every new indicator I add reduces false positives across every rule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The first run showed me things I didn't want to see.&lt;/strong&gt; Discovering four more lies after I thought the crisis was resolved was uncomfortable. But it's exactly why the lint exists. If it never surfaced a real violation, it wouldn't be earning its CI time.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The current state
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Six rules cover 80CCD(1B), HRA, Section 24(b), plus a MathSolver-schema guard (a Google-validator gotcha I ran into weeks earlier and don't want to accidentally re-introduce).&lt;/li&gt;
&lt;li&gt;Safe-indicators list is at 30-odd terms.&lt;/li&gt;
&lt;li&gt;Runs in about 400 ms across 100 mirror files.&lt;/li&gt;
&lt;li&gt;Zero current violations. Sitting on &lt;code&gt;main&lt;/code&gt; as a tripwire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lint is short. The insight is small. But it caught real user-facing YMYL bugs that a human editor and a title-parity lint had both missed. That's a fair trade for 200 lines of PowerShell.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running a Blazor / Astro / Next.js site with a similar hydration pattern and want the full script, the SmartTaxCalc repo is public at &lt;a href="https://github.com/chetan00118/IndiaTaxPlanner" rel="noopener noreferrer"&gt;github.com/chetan00118/IndiaTaxPlanner&lt;/a&gt;. The file is &lt;code&gt;scripts/lint-mirror-integrity.ps1&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you have a YMYL-content-heavy site and no drift guard, this is your polite nudge.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>seo</category>
      <category>software</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Six directory emails promised me a dofollow backlink. My 40-line PowerShell verifier said otherwise.</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:56:16 +0000</pubDate>
      <link>https://dev.to/chetansanghani/six-directory-emails-promised-me-a-dofollow-backlink-my-40-line-powershell-verifier-said-otherwise-37l0</link>
      <guid>https://dev.to/chetansanghani/six-directory-emails-promised-me-a-dofollow-backlink-my-40-line-powershell-verifier-said-otherwise-37l0</guid>
      <description>&lt;p&gt;Every few days now, my inbox gets another version of the same email:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hi there! 👋 We discovered your product and think it's a great fit. Submit it and get a &lt;strong&gt;high-quality dofollow backlink&lt;/strong&gt; from a trusted domain."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In one evening last week I got &lt;strong&gt;six&lt;/strong&gt; of them. Different brand names, same pitch. And because I'm chasing external-validation signals for an AdSense approval, "dofollow backlink" is exactly the phrase designed to make me click.&lt;/p&gt;

&lt;p&gt;I didn't click. I ran a script instead. This post is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why "dofollow" is worth verifying at all (and why the standard SEO guidance is now stale)&lt;/li&gt;
&lt;li&gt;The two silent-nofollow discoveries that made me build a verifier (Medium, GitHub)&lt;/li&gt;
&lt;li&gt;The 40-line PowerShell verifier itself + the header dance to get past bot-detection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The newer trap: sites that actively &lt;em&gt;claim&lt;/em&gt; dofollow — and how the claim falls apart when you check the submission funnel, not just an existing listing&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What "dofollow" means, and why it moves
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; on the web is either dofollow or nofollow. Google treats them very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dofollow&lt;/strong&gt; (&lt;code&gt;rel&lt;/code&gt; doesn't contain &lt;code&gt;nofollow&lt;/code&gt; / &lt;code&gt;ugc&lt;/code&gt; / &lt;code&gt;sponsored&lt;/code&gt;): the source domain vouches for the destination. Passes SEO "link equity." Counts toward the destination's ranking authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nofollow&lt;/strong&gt; / &lt;strong&gt;UGC&lt;/strong&gt; / &lt;strong&gt;Sponsored&lt;/strong&gt;: the source domain explicitly declines to vouch. Google largely discounts these for ranking. Still fine for referral traffic and brand exposure — just doesn't move the SEO needle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an AdSense-approval situation like mine — where the question is "does this site have external validation from real sources" — dofollow domains matter and nofollow domains are decorative.&lt;/p&gt;

&lt;p&gt;The catch: &lt;strong&gt;platforms shift policies.&lt;/strong&gt; What was dofollow in 2020 may be nofollow today, and a cached SEO guide from a few years back gives you the outdated answer with total confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 1: Medium is now &lt;code&gt;ugc nofollow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I republished a home-loan article on Medium expecting the author-bio + in-article links to be dofollow (per most 2020–2022 SEO guides). Curl'd the live article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-sL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://medium.com/@author/article-slug"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'smarttaxcalc'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on every anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"z rm"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in/..."&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener ugc nofollow"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;rel="noopener ugc nofollow"&lt;/code&gt; is Medium's current platform-wide default (rolled out ~2023–2024 to kill SEO spam). Every outbound link on Medium is nofollow now, including your author-bio link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 2: GitHub READMEs are nofollow too — even in your own fork
&lt;/h2&gt;

&lt;p&gt;Same day, I forked an "awesome-personal-finance" repo, added my site, and pushed. Standard advice says: "even if the PR isn't merged, your fork's README anchor is a dofollow link from github.com." Curl the rendered README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://mint.intuit.com/"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Mint&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;SmartTaxCalc&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every external anchor on any GitHub-rendered README is &lt;code&gt;rel="nofollow"&lt;/code&gt;, platform-wide, since ~2020. Even Mint — a heavyweight fintech domain — is nofollow on the upstream repo. Nothing you can do overrides it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both discoveries invalidated ~30 minutes of prep in one afternoon.&lt;/strong&gt; Both would have been caught by a 30-second check &lt;em&gt;before&lt;/em&gt; the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: verify the &lt;code&gt;rel&lt;/code&gt; before you write
&lt;/h2&gt;

&lt;p&gt;Fetch any existing outbound link on the target platform, look at its &lt;code&gt;rel&lt;/code&gt;. If it contains &lt;code&gt;nofollow&lt;/code&gt;, that's the blanket policy — no author, no article, no PR overrides it. I didn't want to remember to do this manually every time, so I wrote it into my weekly analytics script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verifier — 40 lines of PowerShell
&lt;/h2&gt;

&lt;p&gt;The core loop, minus try/catch scaffolding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# e.g. "smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knownReferringUrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TimeoutSec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;-Headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# see next section for the header set&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="c"&gt;# Match every anchor pointing to our host&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;a[^&amp;gt;]*href="[^"]*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'[^"]*"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'rel="([^"]*)"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$Matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="c"&gt;# Dofollow = no nofollow, no ugc, no sponsored in rel.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="c"&gt;# rel="noopener noreferrer" is fine — security attributes, not SEO.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'\b(nofollow|ugc|sponsored)\b'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Milliseconds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;500&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# polite pause between hosts&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.knownReferringUrls&lt;/code&gt; is just a list of URLs where you've published or shared a link back — Medium articles, Dev.to posts, forum threads, directory listings, whatever. Output feeds a scorecard row in my weekly &lt;code&gt;insights.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Dofollow referring domains | 1 | ≥ 5 | 🔴 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The header dance for Cloudflare-fronted sites
&lt;/h2&gt;

&lt;p&gt;A bare &lt;code&gt;Invoke-WebRequest&lt;/code&gt; gets 403'd instantly on Medium, Quora, and anything behind Cloudflare. Even &lt;code&gt;-UserAgent 'Mozilla/5.0'&lt;/code&gt; isn't enough. This header set gets me through most sites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'User-Agent'&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept'&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Language'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'en-US,en;q=0.9'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua'&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Chromium";v="121", "Not A(Brand";v="99"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Mobile'&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?0'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Platform'&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Windows"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Dest'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'document'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Mode'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'navigate'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Site'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Upgrade-Insecure-Requests'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Sec-Ch-Ua*&lt;/code&gt; and &lt;code&gt;Sec-Fetch-*&lt;/code&gt; headers are what Chrome sends automatically that a bare HTTP client doesn't. Even with the full set, some sites still block on a TLS-fingerprint check &lt;code&gt;Invoke-WebRequest&lt;/code&gt; can't spoof — for those the verifier reports &lt;code&gt;❓ Unknown (fetch blocked)&lt;/code&gt; rather than misclassifying. When it can't confirm, it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newer trap: when the site &lt;em&gt;emails you&lt;/em&gt; claiming dofollow
&lt;/h2&gt;

&lt;p&gt;Discoveries 1 and 2 were &lt;em&gt;silent&lt;/em&gt; nofollow — platforms that quietly changed policy. The newer pattern is louder: directory sites that email you promising &lt;strong&gt;"a high-quality dofollow backlink from a trusted domain."&lt;/strong&gt; That's the actual selling point in the pitch.&lt;/p&gt;

&lt;p&gt;So I did what the verifier taught me — checked an existing listing on one of these networks before submitting. And here's the twist that makes this worth a whole section: &lt;strong&gt;the existing listing checked out.&lt;/strong&gt; Outbound product links carried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;rel="noopener noreferrer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;nofollow&lt;/code&gt;, no &lt;code&gt;ugc&lt;/code&gt;. By the rule above, that's dofollow. Naive verdict: "great, submit."&lt;/p&gt;

&lt;p&gt;Then I opened the actual submission form. That's where it fell apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The free tier's dofollow was conditional.&lt;/strong&gt; You got it &lt;em&gt;only if&lt;/em&gt; your product hit a top-3 daily ranking (which needs a vote campaign), &lt;strong&gt;or&lt;/strong&gt; you embedded &lt;em&gt;their&lt;/em&gt; badge on &lt;em&gt;your&lt;/em&gt; site — i.e. a reciprocal link exchange, which is a named Google link-scheme pattern, not a free win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "guaranteed dofollow" was a paid tier.&lt;/strong&gt; Buying a dofollow link is, again, squarely against Google's link-scheme guidelines — the last thing you want pointing at a site mid-review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free launch slots were exhausted on every available date.&lt;/strong&gt; The date picker showed "0 free slots" for every day in range. The paid tier's headline perk? &lt;em&gt;"Skip the free queue."&lt;/em&gt; The free path wasn't a path; it was a funnel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same operator, it turned out, ran several of these under different brand names and emailed me from each. Live listings looked dofollow; the &lt;em&gt;submission&lt;/em&gt; was gated behind a condition or a payment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson upgrade: verify the funnel, not just a listing
&lt;/h3&gt;

&lt;p&gt;Checking an existing listing's &lt;code&gt;rel&lt;/code&gt; is necessary but &lt;strong&gt;not sufficient.&lt;/strong&gt; A network can serve genuinely dofollow HTML on its live pages while gating new &lt;em&gt;free&lt;/em&gt; submissions behind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a ranking condition you can't meet without vote-farming,&lt;/li&gt;
&lt;li&gt;a reciprocal-badge requirement (link scheme), or&lt;/li&gt;
&lt;li&gt;a paid tier (paid link = link scheme).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the check I run now has two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listing check&lt;/strong&gt; (the script above): does an existing outbound link carry a clean &lt;code&gt;rel&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnel check&lt;/strong&gt; (manual, 2 minutes): open the submit form. What tier grants the dofollow? Is the free tier actually available (slots), or zeroed out to push you to pay? Does "free dofollow" require a badge/reciprocal link?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 says "dofollow only via payment or reciprocity," the answer is no — regardless of what step 1 showed. A paid or reciprocal dofollow isn't just low-value; it's an active risk if Google is looking at your link profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than a one-off check
&lt;/h2&gt;

&lt;p&gt;Platform policies drift one-way, and you find out via ranking drops, not announcements: LinkedIn nofollow ~2016, Twitter 2019, Quora 2022, Medium ~2023–2024, GitHub READMEs ~2020. Any pre-2023 SEO guide is potentially wrong about at least one. And the newer directory-spam layer means the &lt;em&gt;claim&lt;/em&gt; of dofollow is now marketing copy, not a fact — the fact lives in the &lt;code&gt;rel&lt;/code&gt; attribute and the submission funnel.&lt;/p&gt;

&lt;p&gt;Two rules I now live by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nobody's claim of "dofollow" counts. The &lt;code&gt;rel&lt;/code&gt; attribute counts.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An existing listing's &lt;code&gt;rel&lt;/code&gt; isn't the offer. The submission tier is the offer — check it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Steal the pattern
&lt;/h2&gt;

&lt;p&gt;The verifier runs alongside my GSC / GA4 / Bing / PageSpeed fetches — one script, one double-click, all marketing signals in one place. Two changes to fit your setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Point &lt;code&gt;$config.siteUrl&lt;/code&gt; and &lt;code&gt;$config.knownReferringUrls&lt;/code&gt; at your domain + your list.&lt;/li&gt;
&lt;li&gt;For 403-heavy platforms, either add retry logic or just report them &lt;code&gt;Unknown&lt;/code&gt; (I do the latter — I already know their policies).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're a solo founder shipping links across platforms and hoping some count toward SEO: run the listing check weekly, run the funnel check before every submission, and stop being surprised.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free, browser-based Indian tax + finance calculator platform. Previously wrote about &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator quirks&lt;/a&gt;, &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP under 2s on Blazor WASM&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;a self-cleaning Product Hunt teaser banner&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>programming</category>
      <category>seo</category>
    </item>
    <item>
      <title>Launching SmartTaxCalc on Product Hunt today 🚀</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:43:26 +0000</pubDate>
      <link>https://dev.to/chetansanghani/launching-smarttaxcalc-on-product-hunt-today-3joh</link>
      <guid>https://dev.to/chetansanghani/launching-smarttaxcalc-on-product-hunt-today-3joh</guid>
      <description>&lt;p&gt;Just went live on Product Hunt: &lt;strong&gt;&lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;SmartTaxCalc — 38 free Indian tax + finance calculators&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've followed my &lt;a href="https://dev.to/chetansanghani"&gt;previous&lt;/a&gt; writeups on the Blazor WASM + Cloudflare Pages stack, this is the culmination — 38 tools, 30+ blog articles, all client-side, CA-reviewed, no signup, no data collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live tools&lt;/strong&gt;: &lt;a href="https://smarttaxcalc.in/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ph_launch_2026&amp;amp;utm_content=launch_post" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — free forever, mobile-friendly, works offline once loaded (Blazor WASM caches locally)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PH launch page&lt;/strong&gt;: &lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;producthunt.com/products/smarttaxcalc&lt;/a&gt; — upvotes appreciated 🙏&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned (5 quick engineering wins from the build)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo"&gt;LCP preload trick&lt;/a&gt;&lt;/strong&gt; — a 5.6 KB JSON at the tail of Blazor's critical chain was costing 5.5s of "element render delay." One &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; fixed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;Getting LCP under 2s on Blazor WASM&lt;/a&gt;&lt;/strong&gt; — six mechanical fixes across a weekend, real commit hashes + before/after numbers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;Self-cleaning PH teaser banner in Blazor&lt;/a&gt;&lt;/strong&gt; — 100 lines, auto-hides after launch date via &lt;code&gt;DateTime.UtcNow &amp;lt; KillDateUtc&lt;/code&gt; in &lt;code&gt;OnInitialized&lt;/code&gt;. No manual removal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;GSC's MathSolver JSON-LD validator is stricter than Schema.org&lt;/a&gt;&lt;/strong&gt; — learned the hard way that "valid according to schema.org" ≠ "valid according to Google's Rich Results validator."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-2dpe"&gt;Medium and GitHub both went nofollow&lt;/a&gt;&lt;/strong&gt; — so I built a 40-line PowerShell backlink verifier that curls each referring page + greps for &lt;code&gt;rel="nofollow"&lt;/code&gt;. Now runs nightly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blazor WebAssembly&lt;/strong&gt; on &lt;strong&gt;Cloudflare Pages&lt;/strong&gt; (free tier)&lt;/li&gt;
&lt;li&gt;Pre-rendered static HTML mirrors for SEO (crawler sees content immediately, Blazor hydrates on top for interactivity)&lt;/li&gt;
&lt;li&gt;No backend — every calculation runs client-side in the browser&lt;/li&gt;
&lt;li&gt;No signup, no email capture, no data sent to any server&lt;/li&gt;
&lt;li&gt;CA-reviewed by an ICAI-registered Chartered Accountant (Ankitkumar Italiya, membership 644575)&lt;/li&gt;
&lt;li&gt;91 razor pages, 91 matching static mirrors, title-parity enforced by pre-commit lint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total spend to date: &lt;strong&gt;~$0&lt;/strong&gt; (Cloudflare Pages free tier + a domain).&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask
&lt;/h2&gt;

&lt;p&gt;If you're a Blazor dev, Indian expat/NRI, or anyone in the CA/tax space — please:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;Upvote on Product Hunt&lt;/a&gt;&lt;/strong&gt; if the tool looks useful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try it&lt;/strong&gt; at &lt;a href="https://smarttaxcalc.in/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ph_launch_2026&amp;amp;utm_content=launch_post" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — 38 calculators to explore&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply here&lt;/strong&gt; with feedback on the tech choices — always curious about how others approach Blazor SEO / CWV / SPA deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: if there's a specific Blazor architectural pattern you want a deep-dive on (like "how to prerender content that Blazor also renders without double-render CLS issues"), drop a comment. Article #7 in the series is TBD.&lt;/p&gt;

&lt;p&gt;Thanks for the support 🙏&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>fintech</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One &lt;link rel=preload&gt; cut my Blazor WASM LCP by 500ms — the invisible file at the end of the critical chain</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Fri, 17 Jul 2026 16:19:20 +0000</pubDate>
      <link>https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo</link>
      <guid>https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo</guid>
      <description>&lt;p&gt;Two weeks ago I &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;wrote about getting my Blazor WebAssembly site's LCP under 2 seconds&lt;/a&gt;. I thought I was done.&lt;/p&gt;

&lt;p&gt;I wasn't.&lt;/p&gt;

&lt;p&gt;Last week I ran a fresh PageSpeed Insights audit and saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FCP:  0.3s   ✅ Great
LCP:  3.5s   ⚠️ Needs Improvement
TBT:  2,360ms 🔴 Poor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FCP was 0.3 seconds. &lt;strong&gt;Something was painted in under a third of a second.&lt;/strong&gt; So how was LCP 3.5 seconds? What was the browser waiting for?&lt;/p&gt;

&lt;p&gt;The "LCP breakdown" panel in PageSpeed had the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subpart              Duration
Time to first byte   0 ms
Element render delay 5,500 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5.5 seconds of "element render delay."&lt;/strong&gt; Not download time. Not parse time. Not layout. Something was preventing the browser from painting the LCP element for five and a half seconds after it was ready.&lt;/p&gt;

&lt;p&gt;Let me walk through the diagnosis and the three-line fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Network dependency tree" was the smoking gun
&lt;/h2&gt;

&lt;p&gt;PageSpeed Insights has an under-appreciated panel: &lt;strong&gt;Network dependency tree&lt;/strong&gt;. It shows the critical request chain — every resource the browser had to fetch before it could paint the LCP element.&lt;/p&gt;

&lt;p&gt;Mine looked like this (edited for brevity):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Maximum critical path latency: 5,464 ms
Initial Navigation
  https://smarttaxcalc.in       169 ms   14.6 KiB
  /js/site-helpers.js          251 ms    8.7 KiB
  /_framework/dotnet.js         642 ms   17.5 KiB
  /_framework/dotnet.runtime… 740 ms   57.7 KiB
  /data/tax-rules.json       5,464 ms    5.6 KiB   ← ??
  /_framework/dotnet.native…    719 ms   37.9 KiB
  /_framework/dotnet.native…wasm  920 ms  1,155.7 KiB
  ... 40 more .wasm files ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;/data/tax-rules.json&lt;/code&gt; line. &lt;strong&gt;5.6 KB. 5,464 ms wall-clock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That file is tiny. Downloading it should take a few hundred milliseconds max. Why was the wall-clock 5.5 seconds?&lt;/p&gt;

&lt;p&gt;Because the browser was fetching it &lt;strong&gt;at the end of the chain&lt;/strong&gt;, after all 43 .wasm assembly files had finished loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens on every Blazor WASM app
&lt;/h2&gt;

&lt;p&gt;Blazor WebAssembly's boot sequence is roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser fetches &lt;code&gt;index.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Parses &lt;code&gt;&amp;lt;script src="_framework/blazor.webassembly.js"&amp;gt;&lt;/code&gt; — starts downloading.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;blazor.webassembly.js&lt;/code&gt; downloads and starts running.&lt;/li&gt;
&lt;li&gt;It fetches &lt;code&gt;blazor.boot.json&lt;/code&gt; (the manifest of runtime + your assemblies).&lt;/li&gt;
&lt;li&gt;It fetches &lt;strong&gt;all&lt;/strong&gt; the .wasm files in parallel.&lt;/li&gt;
&lt;li&gt;It instantiates the runtime.&lt;/li&gt;
&lt;li&gt;It starts your &lt;code&gt;Program.cs&lt;/code&gt; main.&lt;/li&gt;
&lt;li&gt;Your DI container resolves services, including &lt;code&gt;HttpClient&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your app's initial render kicks off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Now&lt;/strong&gt; any &lt;code&gt;HttpClient.GetFromJsonAsync("/data/tax-rules.json")&lt;/code&gt; call runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 10 sits at the end of the wall-clock, waiting for everything above it to finish.&lt;/p&gt;

&lt;p&gt;If your app renders content that depends on that JSON — in my case a tax slabs table that's a large above-fold element — LCP does not fire until step 10 completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FCP fires much earlier&lt;/strong&gt; because the pre-rendered static HTML is painted at step 1. But LCP measures the LARGEST content, and Blazor re-paints the largest content only after step 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — three lines in &lt;code&gt;index.html&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The browser has no idea &lt;code&gt;/data/tax-rules.json&lt;/code&gt; is on the critical path until Blazor's runtime asks for it. But we can tell it, using a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; hint at the very top of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Preload critical assets --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"_framework/blazor.webassembly.js?v=..."&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- NEW: Preload tax-rules.json — end of Blazor's critical request chain --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"fetch"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attributes matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;as="fetch"&lt;/code&gt;&lt;/strong&gt; — matches the &lt;code&gt;HttpClient.GetFromJsonAsync()&lt;/code&gt; request that will happen later. Without this, the browser preloads it as the wrong type and skips using the cached copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetchpriority="high"&lt;/code&gt;&lt;/strong&gt; — hints that the browser should start this fetch immediately, not wait until the current parse yields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;crossorigin&lt;/code&gt;&lt;/strong&gt; — required for &lt;code&gt;as="fetch"&lt;/code&gt; preloads to match a CORS-enabled fetch. Even for same-origin, Blazor's &lt;code&gt;HttpClient&lt;/code&gt; sends CORS-mode requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before / after (real numbers, mobile Lighthouse, Slow 4G throttled)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;0.4s&lt;/td&gt;
&lt;td&gt;0.3s&lt;/td&gt;
&lt;td&gt;▼25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LCP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1985ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1518ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;▼&lt;strong&gt;467ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Element render delay&lt;/td&gt;
&lt;td&gt;5,500ms&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;td&gt;▼97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network critical path latency&lt;/td&gt;
&lt;td&gt;5,464ms&lt;/td&gt;
&lt;td&gt;106ms&lt;/td&gt;
&lt;td&gt;▼98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 5,500ms element render delay collapsed to 180ms. LCP dropped 467ms on mobile.&lt;/p&gt;

&lt;p&gt;Verified a day later via the PageSpeed Insights API in my nightly analytics fetch — the improvement stuck across measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern applies
&lt;/h2&gt;

&lt;p&gt;Anywhere in your Blazor WASM app you're doing something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs or a startup component&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;OnInitializedAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaxConfig&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that JSON blocks the first meaningful render (i.e., the LCP element depends on it), preload it in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. The file can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A JSON config file&lt;/li&gt;
&lt;li&gt;A CSV/TSV data file&lt;/li&gt;
&lt;li&gt;A pre-computed lookup table&lt;/li&gt;
&lt;li&gt;Any other static resource under &lt;code&gt;wwwroot/&lt;/code&gt; that Blazor fetches during boot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the file is under 20 KB it costs nothing to preload. If it's larger, weigh the bandwidth cost against the LCP win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I fell into first — assuming Blazor boot was fixed cost
&lt;/h2&gt;

&lt;p&gt;I spent months blaming Blazor for slow LCP. Every diagnosis started with "Blazor WASM is inherently 3-4 seconds to boot."&lt;/p&gt;

&lt;p&gt;That's true in the aggregate. But the aggregate hid the fact that &lt;strong&gt;one specific 5.6 KB file&lt;/strong&gt; was sitting at the tail of the chain, single-handedly adding 3+ seconds to my perceived boot time.&lt;/p&gt;

&lt;p&gt;If I'd read the "Network dependency tree" panel two months earlier, I would have shipped this fix in October.&lt;/p&gt;

&lt;p&gt;Look at that panel first. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-pattern — don't preload everything
&lt;/h2&gt;

&lt;p&gt;You get one or two "high priority" preload slots that meaningfully move the needle. If you preload every static asset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser can't prioritise the truly critical ones.&lt;/li&gt;
&lt;li&gt;Bandwidth for &lt;code&gt;blazor.boot.json&lt;/code&gt; (which is the actual entry point) gets contended.&lt;/li&gt;
&lt;li&gt;Lighthouse will flag "Unused preload" audits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: preload only resources that would otherwise be at the end of the critical chain — meaning Blazor's runtime asks for them AFTER everything else is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus lessons from the same optimization pass
&lt;/h2&gt;

&lt;p&gt;While I was at it, I also tried:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enabling &lt;code&gt;&amp;lt;RunAOTCompilation&amp;gt;true&amp;lt;/RunAOTCompilation&amp;gt;&lt;/code&gt;&lt;/strong&gt; — Cloudflare Pages CI build failed silently ("No deployment available"). Turns out the CI environment ships the base .NET SDK without the &lt;code&gt;wasm-tools&lt;/code&gt; workload, and &lt;code&gt;dotnet workload restore&lt;/code&gt; in a MSBuild target doesn't pull the emcc + binaryen native binaries. Reverted the same night. If you're on GitHub Actions with proper toolchain, this is worth trying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deferring Google Analytics until first user interaction&lt;/strong&gt; — moved 275ms of gtag.js main-thread evaluation out of Lighthouse's measurement window. TBT dropped ~500ms. Real users still get tracked as soon as they interact.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Disabling Cloudflare Web Analytics beacon&lt;/strong&gt; — 11 KB + 17ms main thread. Redundant if you already use GA4. Toggle it off in the Cloudflare dashboard under Analytics → Web Analytics.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Combined with the preload fix, my mobile Performance score went from 47 (desktop) → 73 (mobile) with mobile LCP firmly in the "Good" range.&lt;/p&gt;

&lt;p&gt;Desktop LCP is still 3.5s though, and that's a longer story — Blazor SPAs replace pre-rendered content on wider viewports differently. That'll be article #6.&lt;/p&gt;




&lt;p&gt;If you're on Blazor WebAssembly and your LCP feels stuck, open PageSpeed Insights, scroll to the Network dependency tree, and look at the last line. There's probably a 5 KB file sitting there costing you seconds.&lt;/p&gt;

&lt;p&gt;The site: &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — a free Indian tax + finance calculator platform. 38 tools, CA-reviewed, all Blazor WebAssembly.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Medium and GitHub both went nofollow — so I built a 40-line PowerShell backlink verifier</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Thu, 09 Jul 2026 17:17:00 +0000</pubDate>
      <link>https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-o41</link>
      <guid>https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-o41</guid>
      <description>&lt;p&gt;I burned about 30 minutes of work on Monday shipping content to Medium and GitHub in the belief they'd give me dofollow backlinks. Both turned out to be platform-wide nofollow. Both discoveries came from a single &lt;code&gt;curl&lt;/code&gt; command that I should have run &lt;em&gt;before&lt;/em&gt; the work, not after.&lt;/p&gt;

&lt;p&gt;This post is: (1) the two discoveries and why the standard SEO guidance is now stale, (2) the 40-line PowerShell verifier I wrote so I never make the mistake again, and (3) the specific header dance you need to actually get past bot-detection on Medium and Quora.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "dofollow" means, and why it moves
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; on the web is either dofollow or nofollow. Google (and other search engines) treats them very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dofollow&lt;/strong&gt; (&lt;code&gt;rel&lt;/code&gt; doesn't contain &lt;code&gt;nofollow&lt;/code&gt; / &lt;code&gt;ugc&lt;/code&gt; / &lt;code&gt;sponsored&lt;/code&gt;): the source domain vouches for the destination. Passes SEO "link equity." Counts toward how Google decides the destination's ranking authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nofollow&lt;/strong&gt; / &lt;strong&gt;UGC&lt;/strong&gt; / &lt;strong&gt;Sponsored&lt;/strong&gt;: the source domain explicitly declines to vouch. Google largely discounts these for ranking purposes. Still useful for referral traffic and brand exposure, but doesn't move the SEO needle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an AdSense-approval situation like mine — where reviewers look at "does this site have external validation from real sources" — dofollow domains matter. Nofollow domains are decorative.&lt;/p&gt;

&lt;p&gt;Now the catch: &lt;strong&gt;platforms shift policies.&lt;/strong&gt; What was dofollow in 2020 may be nofollow today, and cached SEO guides from a few years back get you the outdated answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 1: Medium is now &lt;code&gt;ugc nofollow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I republished a Section 24(b) home-loan article on Medium, expecting the author-bio + in-article links to be dofollow (per most SEO guides from 2020-2022). Curl'd the live article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-sL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://medium.com/@author/article-slug"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'smarttaxcalc'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on every anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"z rm"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in/..."&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener ugc nofollow"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;rel="noopener ugc nofollow"&lt;/code&gt; is Medium's current default — they &lt;a href="https://help.medium.com/" rel="noopener noreferrer"&gt;rolled it out platform-wide around 2023-2024&lt;/a&gt; to kill SEO spam. Every outbound link on Medium is now nofollow, including your author-bio link. Google discounts them all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 2: GitHub READMEs are nofollow too — even in your own fork
&lt;/h2&gt;

&lt;p&gt;Same day, I forked an "awesome-personal-finance" repo, added SmartTaxCalc to the list, and pushed. Standard SEO advice says: "even if the PR isn't merged, your fork at &lt;code&gt;github.com/YOUR-USER/awesome-XXX/README.md&lt;/code&gt; still has the anchor to your site — that's a dofollow link from github.com."&lt;/p&gt;

&lt;p&gt;Wrong, at least since ~2020. Curl the rendered README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://mint.intuit.com/"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Mint&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;SmartTaxCalc&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every external anchor on any GitHub-rendered README is &lt;code&gt;rel="nofollow"&lt;/code&gt;. GitHub applies this platform-wide to reduce link-farming via repo forks. Even Mint on the upstream repo (a heavily authoritative domain in the fintech space) is nofollow. There's nothing you can do to force it dofollow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both discoveries invalidated ~30 minutes of preparation work in the same afternoon.&lt;/strong&gt; The Medium article was still worth publishing (referral traffic + canonical protection so Medium doesn't outrank the original), but I wouldn't have spent time on the GitHub PR pack if I'd known.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: curl before you write
&lt;/h2&gt;

&lt;p&gt;Both would have been caught by a 30-second check &lt;em&gt;before&lt;/em&gt; the work: fetch any existing outbound link on the target platform, look at its &lt;code&gt;rel&lt;/code&gt; attribute. If it contains &lt;code&gt;nofollow&lt;/code&gt;, that's the platform's blanket policy — no author, no article, no PR can override it.&lt;/p&gt;

&lt;p&gt;But I don't want to remember to do this manually every time. So I wrote it into my weekly analytics script. Every Sunday, along with GSC/GA4/Bing exports, the script now curls every referring URL I care about and reports which anchors are dofollow vs nofollow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verifier — 40 lines of PowerShell
&lt;/h2&gt;

&lt;p&gt;The core loop, minus try/catch scaffolding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# e.g. "smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knownReferringUrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TimeoutSec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;-Headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# see next section for the header set&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="c"&gt;# Match every anchor pointing to our host&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;a[^&amp;gt;]*href="[^"]*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'[^"]*"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'rel="([^"]*)"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$Matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="c"&gt;# Dofollow = no nofollow, no ugc, no sponsored in rel.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="c"&gt;# rel="noopener noreferrer" is fine — it's a security attribute, not SEO.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'\b(nofollow|ugc|sponsored)\b'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Milliseconds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;500&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# polite pause between hosts&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.knownReferringUrls&lt;/code&gt; is just a list of URLs where you've published or shared a link back to your site — Medium article URLs, Quora answer URLs, Dev.to posts, forum posts, whatever.&lt;/p&gt;

&lt;p&gt;Output goes into a CSV (&lt;code&gt;backlink-rel-verification.csv&lt;/code&gt;) and — this is where it stops being just a report — feeds a scorecard row in my weekly insights.md:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Dofollow referring domains | 1 | ≥ 5 | 🔴 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Medium ever &lt;em&gt;does&lt;/em&gt; switch back to dofollow (unlikely), or a new post I published turns out to be a hidden dofollow win, I'll see it in Sunday's report without doing anything. Same in reverse: if a platform I currently count as dofollow shifts to nofollow silently, the scorecard row updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header dance for Medium and Quora
&lt;/h2&gt;

&lt;p&gt;The naive &lt;code&gt;Invoke-WebRequest -Uri $url -UseBasicParsing&lt;/code&gt; gets 403'd immediately on Medium, Quora, and any Cloudflare-fronted site. Even adding &lt;code&gt;-UserAgent 'Mozilla/5.0'&lt;/code&gt; isn't enough — they check other headers too.&lt;/p&gt;

&lt;p&gt;This is the header set that gets me through most sites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'User-Agent'&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept'&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Language'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'en-US,en;q=0.9'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Encoding'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'gzip, deflate, br'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua'&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Chromium";v="121", "Not A(Brand";v="99"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Mobile'&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?0'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Platform'&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Windows"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Dest'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'document'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Mode'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'navigate'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Site'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-User'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?1'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Upgrade-Insecure-Requests'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Sec-Ch-Ua*&lt;/code&gt; and &lt;code&gt;Sec-Fetch-*&lt;/code&gt; headers are what Chrome sends automatically that a bare HTTP client doesn't. Without them, Cloudflare's bot detection has a very high true-positive rate on your requests.&lt;/p&gt;

&lt;p&gt;Even with the full set, &lt;strong&gt;Medium and Quora still block on my Windows box&lt;/strong&gt; — probably a TLS fingerprint check that &lt;code&gt;Invoke-WebRequest&lt;/code&gt; can't spoof (curl.exe fails the same way). For those, the verifier reports &lt;code&gt;❓ Unknown (fetch blocked)&lt;/code&gt; rather than misclassifying as nofollow. When it can't confirm, it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scorecard actually shows
&lt;/h2&gt;

&lt;p&gt;Running against 6 known referring URLs — 3 Dev.to articles, 1 Medium republish, 1 GitHub Awesome-List fork, 1 Quora answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Source     | Rel                     | Dofollow?               |
|------------|-------------------------|-------------------------|
| dev.to     | noopener noreferrer     | ✅ Yes (x10 anchors)    |
| medium.com | (fetch blocked 403)     | ❓ Unknown              |
| github.com | nofollow                | ❌ No                   |
| quora.com  | (fetch blocked 403)     | ❓ Unknown              |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unique dofollow referring domains: 1&lt;/strong&gt; (dev.to). Both Medium and GitHub, which I &lt;em&gt;thought&lt;/em&gt; would boost this count to 3, contribute zero. The Quora and Medium fetch blocks aren't a huge loss — I already know from a prior manual curl (on a Linux box with a different TLS fingerprint) that both are nofollow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than a one-off check
&lt;/h2&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One&lt;/strong&gt;: platform policies drift, and you find out via ranking drops rather than an announcement. LinkedIn made outbound links nofollow around 2016. Twitter went nofollow in 2019. Medium moved to &lt;code&gt;ugc nofollow&lt;/code&gt; around 2023-2024. Quora went nofollow-by-default in 2022. GitHub went nofollow on README external links around 2020. The pattern is one-way — platforms don't go &lt;em&gt;back&lt;/em&gt; to dofollow. Any pre-2023 SEO guide you're reading is potentially wrong about at least one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two&lt;/strong&gt;: if you publish enough content across enough platforms, you lose track. I have 3 Dev.to articles, 2 Quora answers, 34 LinkedIn Company Page posts, 1 Medium republish, and 1 GitHub fork. Manually curling each one every Sunday to check the rel attribute is real work. Encoding it into the analytics script — 40 lines of PowerShell — means it becomes a green/red row on the weekly report, and I only pay attention when something changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steal the pattern
&lt;/h2&gt;

&lt;p&gt;The full script lives in &lt;a href="https://github.com/chetan00118/IndiaTaxPlanner/blob/main/analytics/fetch-analytics.ps1" rel="noopener noreferrer"&gt;SmartTaxCalc's analytics folder&lt;/a&gt; (search for &lt;code&gt;BACKLINK REL-ATTRIBUTE VERIFIER&lt;/code&gt;). It runs alongside GSC / GA4 / Bing / PageSpeed fetches — one script, one double-click, all your marketing signals in one place.&lt;/p&gt;

&lt;p&gt;Two changes to make it fit your setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Change &lt;code&gt;$config.siteUrl&lt;/code&gt; and &lt;code&gt;$config.knownReferringUrls&lt;/code&gt; to your domain + your list&lt;/li&gt;
&lt;li&gt;Depending on your platform mix, you might want to add retry logic for the 403-heavy platforms — I chose to just report them as &lt;code&gt;Unknown&lt;/code&gt; because I already know their policies&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're an indie hacker or solo founder shipping content to multiple platforms and hoping some of them count toward your SEO signal, run this once a week and stop being surprised.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free, browser-based Indian tax + finance calculator platform. Previously wrote about &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator quirks&lt;/a&gt;, &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP under 2s on Blazor WASM&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;a self-cleaning Product Hunt teaser banner&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>programming</category>
      <category>seo</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A self-cleaning Product Hunt teaser banner in Blazor WASM — 100 lines, auto-hides after launch, GA4-tracked</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Mon, 06 Jul 2026 15:47:16 +0000</pubDate>
      <link>https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p</link>
      <guid>https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p</guid>
      <description>&lt;p&gt;I'm launching &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; on Product Hunt on &lt;strong&gt;Tuesday, 14 July 2026&lt;/strong&gt;. It's a 38-tool Blazor WebAssembly tax + finance calculator I've written about here before (&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;the SEO/schema saga&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP from 6-8s to under 2s&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The Product Hunt launch algorithm heavily rewards products that arrive with a real coming-soon follower base — day-of upvotes correlate strongly with pre-launch "Notify me" clicks. My PH page started with &lt;strong&gt;1 follower&lt;/strong&gt;. I had 9 days to get to 50+.&lt;/p&gt;

&lt;p&gt;The obvious answer: post on LinkedIn, ask friends, DM your network. All of that has ceilings (you can only ask a favor once). The non-obvious answer that has no ceiling: &lt;strong&gt;convert your own organic search traffic&lt;/strong&gt; into PH followers automatically.&lt;/p&gt;

&lt;p&gt;This is the ~100 lines of Blazor code that does that, plus the design decisions I made along the way. It's also self-cleaning — after the launch date, the banner disappears with no manual work required. Steal the pattern for your own launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;SmartTaxCalc gets modest but real organic traffic — mostly from Google Search Console impressions on tax-season queries. That traffic is the warmest possible audience for a PH launch (they already found the site, they're in the target demo). But how do you route them to a PH page without:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disrupting the tax content (they came for a tax calculator, not a marketing pitch)&lt;/li&gt;
&lt;li&gt;Cannibalizing the existing tax-season banner (which drives users to /tax-calendar/ — a real retention lever)&lt;/li&gt;
&lt;li&gt;Leaving code debt after 14 July (a dead PH banner still on the site in September)&lt;/li&gt;
&lt;li&gt;Losing the dismiss preference across page navigations (SPA reality — no page refresh)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those constraints ruled out a modal, a full-width interrupt, and a "hardcoded remove after launch" approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;p&gt;Slim horizontal bar at the top of every page. Sits ABOVE the existing tax-season banner. PH-brand orange, different from the tax-season banner's yellow/red so both are visually distinguishable when stacked. Dismissible per-user via &lt;code&gt;localStorage&lt;/code&gt;. Auto-hides after a hard kill date. GA4 events on click + dismiss for measurement.&lt;/p&gt;

&lt;p&gt;Structurally, it mirrors the existing &lt;code&gt;TaxSeasonBanner.razor&lt;/code&gt; conventions (same DOM shape, same a11y attributes) so the two components have consistent tab-order and screen-reader behavior when both are visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The component
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Shared/ProductHuntTeaserBanner.razor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@inject IJSRuntime JS

@if (Visible &amp;amp;&amp;amp; !Dismissed)
{
    &amp;lt;aside class="ph-teaser-banner" role="complementary"
           aria-label="Product Hunt launch teaser"&amp;gt;
        &amp;lt;div class="ph-teaser-banner-content"&amp;gt;
            &amp;lt;span class="ph-teaser-banner-icon" aria-hidden="true"&amp;gt;🚀&amp;lt;/span&amp;gt;
            &amp;lt;span class="ph-teaser-banner-text"&amp;gt;
                &amp;lt;strong&amp;gt;Launching on Product Hunt Tue 14 July&amp;lt;/strong&amp;gt;
                &amp;lt;span class="ph-teaser-banner-sub"&amp;gt;Follow to get notified when we go live.&amp;lt;/span&amp;gt;
            &amp;lt;/span&amp;gt;
            &amp;lt;a class="ph-teaser-banner-cta"
               href="https://www.producthunt.com/products/smarttaxcalc"
               target="_blank"
               rel="noopener"
               @onclick="OnCtaClicked"&amp;gt;
                Notify me on PH →
            &amp;lt;/a&amp;gt;
            &amp;lt;button class="ph-teaser-banner-close"
                    @onclick="OnDismissClicked"
                    aria-label="Dismiss Product Hunt teaser"
                    type="button"&amp;gt;&amp;amp;times;&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/aside&amp;gt;
}

@code {
    // Kill date: banner stops showing after this instant regardless of dismissal state.
    // 2026-07-14 23:59 IST = 18:29 UTC — one full day AFTER launch so day-of visitors
    // see it, but 07-15 onwards is clean.
    private static readonly DateTime KillDateUtc =
        new DateTime(2026, 7, 14, 18, 29, 0, DateTimeKind.Utc);

    private const string DismissKey = "ph-teaser-dismissed-2026-07-14";

    private bool Visible;
    private bool Dismissed;

    protected override void OnInitialized()
    {
        // Server-side timing gate. If we're past the kill date, don't render at all —
        // no localStorage check needed, no re-render churn, no bytes shipped.
        Visible = DateTime.UtcNow &amp;lt; KillDateUtc;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender &amp;amp;&amp;amp; Visible)
        {
            try
            {
                var dismissed = await JS.InvokeAsync&amp;lt;string?&amp;gt;(
                    "localStorage.getItem", DismissKey);
                if (dismissed == "1")
                {
                    Dismissed = true;
                    StateHasChanged();
                }
            }
            catch
            {
                // localStorage unavailable (private mode, edge cases) — banner just shows.
                // Failing "open" is better than failing "hidden" for a follower-growth tool.
            }
        }
    }

    private async Task OnCtaClicked()
    {
        try
        {
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_cta_clicked",
                new { launch = "2026-07-14" });
        }
        catch { }
    }

    private async Task OnDismissClicked()
    {
        Dismissed = true;
        try
        {
            await JS.InvokeVoidAsync("localStorage.setItem", DismissKey, "1");
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_dismissed",
                new { launch = "2026-07-14" });
        }
        catch { }
        StateHasChanged();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. About 90 lines of C# + Razor including comments and whitespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth calling out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Kill-date is server-side, dismiss is client-side
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;OnInitialized&lt;/code&gt; gate runs in Blazor's render tree before any HTML is emitted. After 2026-07-14 18:29 UTC, the whole &lt;code&gt;&amp;lt;aside&amp;gt;&lt;/code&gt; never renders — no wasted bytes, no dead DOM, no need for the JS to check anything.&lt;/p&gt;

&lt;p&gt;The dismiss check has to be client-side because it depends on the user's &lt;code&gt;localStorage&lt;/code&gt;. So it lives in &lt;code&gt;OnAfterRenderAsync&lt;/code&gt; and updates via &lt;code&gt;StateHasChanged&lt;/code&gt; if the user previously dismissed.&lt;/p&gt;

&lt;p&gt;This split matters. If I'd tried to do the kill-date check client-side too, I'd have shipped the banner HTML to every user post-launch, then hidden it with JS — wasteful and briefly visible during hydration. Server-side gate is free.&lt;/p&gt;

&lt;h3&gt;
  
  
  localStorage key is versioned by launch
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DismissKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ph-teaser-dismissed-2026-07-14"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-2026-07-14&lt;/code&gt; suffix means my next PH launch (if I do a re-launch or feature launch later) uses a different key — users who dismissed the July teaser will still see the future teaser. No dismiss-forever bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failing open, not closed
&lt;/h3&gt;

&lt;p&gt;The try/catch around &lt;code&gt;localStorage.getItem&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// localStorage unavailable (private mode, edge cases) — banner just shows.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If localStorage throws (Safari private mode, ITP restrictions, some enterprise browsers), we DON'T hide the banner — we show it. For a follower-growth tool, false-positive display beats false-negative hiding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kill date uses UTC everywhere
&lt;/h3&gt;

&lt;p&gt;The Cloudflare Pages server runs in UTC. The user's browser could be in any timezone. Blazor WASM runs in the browser but I'm doing the check at component initialization which happens after WASM boots — timezone at that point depends on how Blazor was invoked. Sticking with &lt;code&gt;DateTime.UtcNow&lt;/code&gt; + a UTC kill-date instant sidesteps every possible drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS — matching existing patterns
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.ph-teaser-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#ff6b35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#da552f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* PH brand orange */&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;flex-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-cta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.35rem&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-close&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dark-mode&lt;/span&gt; &lt;span class="nc"&gt;.ph-teaser-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#c94820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#a83a1b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;32×32 minimum touch targets&lt;/strong&gt; on the CTA and dismiss button (WCAG 2.5.5 — Success Criterion for touch target size). This is easy to forget on slim horizontal bars where the visual height is smaller. The button padding + &lt;code&gt;min-height: 32px&lt;/code&gt; guarantees the tap area meets the standard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dark-mode variant&lt;/strong&gt; because SmartTaxCalc has a full dark-mode toggle. Any new component that ships without a &lt;code&gt;.dark-mode .foo&lt;/code&gt; variant looks jarring for the ~30% of users on dark mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring into layout
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Layout/MainLayout.razor&lt;/code&gt; — one line, above the existing tax-season banner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;header class="app-header"&amp;gt;...&amp;lt;/header&amp;gt;

@* PH launch teaser — active 2026-07-05 → 2026-07-14 EOD IST *@
&amp;lt;ProductHuntTeaserBanner /&amp;gt;

@* Tax-season-aware banner — May-Jul ITR, Dec advance tax, Mar year-end *@
&amp;lt;TaxSeasonBanner /&amp;gt;

&amp;lt;main class="main-content"&amp;gt;
    @Body
&amp;lt;/main&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stacking order matters. PH banner is time-critical (9-day window), tax banner is a permanent retention CTA. Time-critical goes on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache-buster gotcha
&lt;/h2&gt;

&lt;p&gt;Cloudflare Pages caches CSS aggressively. Deploying the new CSS with the same filename means returning users don't see the new styles until their browser cache expires (often hours or days).&lt;/p&gt;

&lt;p&gt;Every CSS/JS reference on the site uses a query-string version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260705a"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/_framework/blazor.webassembly.js?v=20260620j"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On any deploy that ships new CSS, bump the &lt;code&gt;v=&lt;/code&gt; string. I ran this one-liner to update every static HTML file in the wwwroot mirror:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;perl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-pe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'s|app\.css\?v=20260703a|app.css?v=20260705a|g'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-rlE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'app\.css\?v=20260703a'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;frontend/web/TaxPlanner.Web/wwwroot/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skipping this step meant users on returning-visit cache would see the banner in DOM (Blazor renders it) but with &lt;strong&gt;no styles&lt;/strong&gt; — invisible text/button on a normal background. Nothing worse than a launch banner that only works for first-visit users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The telemetry
&lt;/h2&gt;

&lt;p&gt;Two GA4 events I care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ph_teaser_cta_clicked&lt;/code&gt; — fires when the user clicks "Notify me". This is the metric that matters. It correlates directly with PH follower growth.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ph_teaser_dismissed&lt;/code&gt; — fires when the user clicks the ×. If this is &amp;gt;30% of impressions, the banner is annoying and I should tune it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't track banner impressions separately — Blazor renders it if &lt;code&gt;OnInitialized&lt;/code&gt; allows AND the user hasn't dismissed. Impression count = pageview count from GA4 minus &lt;code&gt;ph_teaser_dismissed&lt;/code&gt; count minus post-kill-date pageviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realistic conversion math
&lt;/h2&gt;

&lt;p&gt;Here's the funnel math I set targets against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily organic pageviews on SmartTaxCalc: ~50-80 (Indian tax queries)&lt;/li&gt;
&lt;li&gt;Percent of pageviews that click Notify me: 2-5% (informed guess; will measure)&lt;/li&gt;
&lt;li&gt;Expected daily new followers: 1-4 from the banner alone&lt;/li&gt;
&lt;li&gt;Nine days of banner active pre-launch: 9-36 followers&lt;/li&gt;
&lt;li&gt;Combined with LinkedIn + personal DMs: target 50+&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;50 pre-launch followers is the informal PH threshold for "you have a shot at Top 20 of the day." Below 20 and the launch is dead on arrival. Above 100 and you're guaranteed Top 10 barring a bad launch-day thumbnail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I skipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Modal/interrupt version&lt;/strong&gt;: kills UX for the tax content (users came for a calculator, not a launch pitch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie banner-style at page bottom&lt;/strong&gt;: lower click-through, competes visually with the actual cookie consent banner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homepage-only version&lt;/strong&gt;: 60% of my traffic lands on deep pages (calculator URLs from Google), not the homepage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual removal after launch&lt;/strong&gt;: not scalable if I do another launch later — kill-date automates the "remove" step&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Time-bounded UI needs a self-cleaning mechanism from day one.&lt;/strong&gt; Every "temporary" banner I've seen without a kill date has ended up living on production long past its usefulness. The kill-date constant is 3 lines of code and saves you a manual removal PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Convert your own traffic before you buy other people's.&lt;/strong&gt; LinkedIn ads, PH-influencer shoutouts, upvote-swap groups — all lower conversion than your own site visitors who already trust you. If you're launching anything, this component (or the equivalent in your stack) is the highest-leverage growth code you can ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch itself
&lt;/h2&gt;

&lt;p&gt;If you use tax calculators or you're a founder / indie hacker who wants to follow the launch curve, &lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;click Notify me on our PH page&lt;/a&gt;. Launch is &lt;strong&gt;Tuesday 14 July 2026 at 12:01 AM PDT (12:31 PM IST)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If this banner pattern is useful, feel free to lift it. Blazor + Razor + a few lines of CSS is all it takes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About me&lt;/strong&gt;: I'm Chetan Sanghani — I build &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc&lt;/a&gt;, a free CA-reviewed Indian tax calculator platform. This is my third article on Dev.to in the "Building SmartTaxCalc" series. Previous posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator is stricter than schema.org — I learned that the hard way over 4 rejections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;How I got LCP under 2s on a 38-page Blazor WebAssembly site (Cloudflare Pages)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>analytics</category>
      <category>dotnet</category>
      <category>marketing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I got LCP under 2s on a 38-page Blazor WebAssembly site (Cloudflare Pages)</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Sat, 04 Jul 2026 11:06:06 +0000</pubDate>
      <link>https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec</link>
      <guid>https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec</guid>
      <description>&lt;p&gt;I run &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free Indian tax calculator platform built as a Blazor WebAssembly SPA on Cloudflare Pages. 38 tools, 20+ blog articles, all client-side. Very static, very cheap to host, easy to iterate.&lt;/p&gt;

&lt;p&gt;For the first several months, mobile Largest Contentful Paint (LCP) was &lt;strong&gt;6-8 seconds&lt;/strong&gt;. That's failing Core Web Vitals by a mile. Every Lighthouse run screamed at me. And every SEO agency told me the same three things:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's Blazor. It's slow. Rewrite in Next.js."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yeah, no. I like Blazor. I've shipped .NET for a decade. I don't want to rewrite for the sake of a Lighthouse score.&lt;/p&gt;

&lt;p&gt;So over one weekend I ran a proper performance pass. Six mechanical fixes, six commits, mostly one-liners. By the end of it, mobile LCP was &lt;strong&gt;under 2 seconds&lt;/strong&gt; — well inside the "Good" threshold — on every measured page.&lt;/p&gt;

&lt;p&gt;Here's exactly what worked. Real commit hashes, real numbers, and honest notes on what didn't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup — what "LCP on Blazor WASM" actually means
&lt;/h2&gt;

&lt;p&gt;Blazor WebAssembly is a Single Page Application. The initial HTML is a small shell (&lt;code&gt;index.html&lt;/code&gt;) with a &lt;code&gt;&amp;lt;base href="/"&amp;gt;&lt;/code&gt;, some CSS &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags, and a &lt;code&gt;&amp;lt;script src="_framework/blazor.webassembly.js"&amp;gt;&lt;/code&gt;. That JavaScript downloads the .NET runtime, your app DLLs, and boots the Blazor renderer, which then constructs the DOM.&lt;/p&gt;

&lt;p&gt;LCP is measured on the largest content element rendered above the fold. On a fresh Blazor site, that element usually doesn't exist until Blazor finishes booting and renders your first component. So "LCP" and "time-to-Blazor-hydrated" tend to be the same thing.&lt;/p&gt;

&lt;p&gt;That said — a well-architected Blazor site with static HTML mirrors (pre-rendered content) can have LCP fire on the static content BEFORE Blazor hydrates, using Blazor purely for interactivity. That's the model my site uses.&lt;/p&gt;

&lt;p&gt;So the LCP battle here is really a "get the browser to render the largest text/image element as fast as possible" battle. Blazor still boots, but LCP doesn't wait for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before numbers — Lighthouse mobile, throttled 4G, Moto G4 CPU
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Mobile LCP (before)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;~6.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax calculator (deep interactive page)&lt;/td&gt;
&lt;td&gt;~7.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog article (mostly static content)&lt;/td&gt;
&lt;td&gt;~5.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 6-8s range is what you get when you serve unminified CSS, ship a service worker that races the main thread, and don't preconnect to your CDN edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Delete the service worker (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The counter-intuitive one.&lt;/p&gt;

&lt;p&gt;I originally shipped a service worker in June 2026 to fix a different bug (stale HTML cache after a build-tool migration). The SW's activate handler would detect its own version, unregister itself, and clear all caches. It was a one-time cleanup for existing users who were pinned to the old broken cache.&lt;/p&gt;

&lt;p&gt;Two weeks later, every recurring visitor had had that cleanup fire. &lt;strong&gt;New visitors didn't need a service worker at all&lt;/strong&gt; — but I was still registering one on every page load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — every page did this&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/service-worker.js?v=cleanup-20260613&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;updateViaCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removing that block saved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One network request (&lt;code&gt;/service-worker.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;~50-100ms of post-load main-thread work as the browser evaluates the SW script&lt;/li&gt;
&lt;li&gt;One CPU spike that competed with Blazor's own boot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~200ms LCP win.&lt;/p&gt;

&lt;p&gt;I left the &lt;code&gt;service-worker.js&lt;/code&gt; file in &lt;code&gt;wwwroot&lt;/code&gt; for now — old bookmarks that had registered the SW will still fetch it and run the self-unregister logic. That's a slow-decay migration, safe to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Removed a version-check IIFE that force-reloaded users (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;This one hurt when I found it.&lt;/p&gt;

&lt;p&gt;Buried in &lt;code&gt;index.html&lt;/code&gt; was a small self-invoking function that read a &lt;code&gt;version&lt;/code&gt; field from &lt;code&gt;/version.json&lt;/code&gt;, compared it to a value stored in &lt;code&gt;localStorage&lt;/code&gt;, and if they mismatched, called &lt;code&gt;location.reload(true)&lt;/code&gt; — a hard reload.&lt;/p&gt;

&lt;p&gt;The intent was noble: force existing users to fetch new HTML after each deploy so they'd never see stale content. But the effect on a first-visit user was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser fetches &lt;code&gt;index.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browser starts parsing → hits the IIFE&lt;/li&gt;
&lt;li&gt;IIFE fetches &lt;code&gt;/version.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IIFE reads &lt;code&gt;localStorage&lt;/code&gt;, finds no version stored → sets &lt;code&gt;oldVersion = null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IIFE sees &lt;code&gt;null !== '20260620j'&lt;/code&gt; → calls &lt;code&gt;location.reload(true)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser tears down the entire page and starts over.&lt;/strong&gt; LCP timer resets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first-visit user was silently reloading their own page before LCP could fire. Which meant their LCP was measured on the SECOND load, but the timer count-up started from the FIRST navigation. Result: catastrophic LCP.&lt;/p&gt;

&lt;p&gt;Deleted the IIFE entirely. LCP-first-visit dropped by an estimated 2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~2000ms LCP win on first visits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Preconnect coverage on 7 pages that were missing (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;link rel="preconnect"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;link rel="dns-prefetch"&amp;gt;&lt;/code&gt; don't do anything visible, but on a slow mobile connection they save hundreds of milliseconds by opening DNS + TCP + TLS connections to third-party origins BEFORE the browser actually needs them.&lt;/p&gt;

&lt;p&gt;I had preconnect hints on the homepage and some tools, but 7 static HTML mirrors were missing them entirely. Namely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://pagead2.googlesyndication.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://adservice.google.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://ep1.adtrafficquality.google"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the origins my site talks to for analytics + AdSense (site-wide). Without preconnect, the browser has to do the DNS lookup + TCP handshake + TLS negotiation only when the first request to those origins fires — which is AFTER my HTML is fully parsed.&lt;/p&gt;

&lt;p&gt;With preconnect, those handshakes happen IN PARALLEL with HTML parsing. On a 4G mobile connection, that's ~300-500ms saved.&lt;/p&gt;

&lt;p&gt;Added the four &lt;code&gt;preconnect&lt;/code&gt;/&lt;code&gt;dns-prefetch&lt;/code&gt; lines to every static HTML mirror. Nothing fancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~300ms LCP win on cold pageviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Cache-buster pattern for CSS/JS (commit &lt;code&gt;a9255e8&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Not a direct LCP fix, but a prerequisite for all the others actually reaching users.&lt;/p&gt;

&lt;p&gt;If your CSS is at &lt;code&gt;/css/app.css&lt;/code&gt; and you update it, browsers and CDN edges will keep serving the old content — because &lt;code&gt;/css/app.css&lt;/code&gt; is the same URL as yesterday. Cache poisoning.&lt;/p&gt;

&lt;p&gt;Standard fix: append a version query string that changes on every deploy: &lt;code&gt;/css/app.css?v=20260703a&lt;/code&gt;. When the version changes, the URL changes, and every cache treats it as a new resource.&lt;/p&gt;

&lt;p&gt;Baked this into every HTML mirror:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"style"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260703a"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260703a"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/_framework/blazor.webassembly.js?v=20260620j"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also added &lt;code&gt;fetchpriority="high"&lt;/code&gt; on the CSS &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; — a browser hint that tells Chrome to prioritize this request over async scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: without this, none of the other CSS/JS fixes would deploy reliably. LCP win: indirect but load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: Cloudflare Brotli edge compression
&lt;/h2&gt;

&lt;p&gt;This one you don't code — you enable it in the Cloudflare dashboard. But it's the biggest single win of the six.&lt;/p&gt;

&lt;p&gt;My app.css is 131KB raw. That's not small. GZIP compresses it to ~40KB on the wire (typical text ratio 3:1). Brotli — Cloudflare's default compression for modern browsers — takes it to &lt;strong&gt;~24KB&lt;/strong&gt; (ratio 5.5:1).&lt;/p&gt;

&lt;p&gt;For a slow 4G mobile connection at 1 Mbps effective throughput:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;131KB raw = ~1050ms transfer time&lt;/li&gt;
&lt;li&gt;40KB gzip = ~320ms transfer time&lt;/li&gt;
&lt;li&gt;24KB brotli = ~192ms transfer time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a ~700ms LCP win on the render-blocking CSS alone. FREE. Just toggle Brotli in Cloudflare Speed → Optimization.&lt;/p&gt;

&lt;p&gt;If you're on Cloudflare and Brotli isn't on, this is your fastest-possible perf win. Zero code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~700ms LCP win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 6: Blazor WASM bundle size — &lt;code&gt;PublishTrimmed&lt;/code&gt; + linking + InvariantGlobalization
&lt;/h2&gt;

&lt;p&gt;The .NET runtime + your app DLLs downloaded on first visit is normally 3-5MB of WASM. That's a lot for a mobile connection. Three project-file switches shrink it significantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PublishTrimmed&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishTrimmed&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;BlazorWebAssemblyEnableLinking&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/BlazorWebAssemblyEnableLinking&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;InvariantGlobalization&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/InvariantGlobalization&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PublishTrimmed&lt;/code&gt; — .NET's IL linker strips unused types. Removes everything from mscorlib you're not calling. Typically 50-70% size reduction.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BlazorWebAssemblyEnableLinking&lt;/code&gt; — same idea but Blazor-specific.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InvariantGlobalization&lt;/code&gt; — removes the ICU globalization data (~1.5MB). Only viable if you don't need locale-aware string formatting. For a tax calculator that runs in one language, safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined effect on my site: Blazor bundle dropped from ~5MB to ~1.8MB. That's a big deal on first-visit mobile.&lt;/p&gt;

&lt;p&gt;Note: this doesn't affect LCP directly IF your static HTML already renders the content (which mine does). But it dramatically speeds up time-to-interactive, which affects INP (Interaction to Next Paint) — the other Core Web Vital.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: 200-400ms INP win + faster hydration.&lt;/p&gt;

&lt;h2&gt;
  
  
  After numbers — Lighthouse mobile, same throttling profile
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;LCP (before)&lt;/th&gt;
&lt;th&gt;LCP (after)&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;6.2s&lt;/td&gt;
&lt;td&gt;1.84s&lt;/td&gt;
&lt;td&gt;-4.36s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency converter&lt;/td&gt;
&lt;td&gt;7.1s&lt;/td&gt;
&lt;td&gt;1.21s&lt;/td&gt;
&lt;td&gt;-5.89s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salary tax guide&lt;/td&gt;
&lt;td&gt;5.9s&lt;/td&gt;
&lt;td&gt;1.56s&lt;/td&gt;
&lt;td&gt;-4.34s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax calendar&lt;/td&gt;
&lt;td&gt;5.5s&lt;/td&gt;
&lt;td&gt;1.55s&lt;/td&gt;
&lt;td&gt;-3.95s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HRA calculator&lt;/td&gt;
&lt;td&gt;7.4s&lt;/td&gt;
&lt;td&gt;1.54s&lt;/td&gt;
&lt;td&gt;-5.86s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every measured page is now under the 2.5s "Good" LCP threshold. Most are under 2s.&lt;/p&gt;

&lt;p&gt;The homepage cut ~4.4 seconds from real user-facing render time with 6 commits of mechanical work. No framework rewrite. Blazor is still Blazor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did NOT help (I'm being honest)
&lt;/h2&gt;

&lt;p&gt;Not everything I tried worked. Skip these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minifying CSS manually&lt;/strong&gt;. Cloudflare's Brotli already compresses better than any hand-minifier. Diminishing returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-loading below-fold sections with &lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/strong&gt;. Improved paint but caused CLS (layout shifts) that hurt the CLS Core Web Vital more than the LCP improvement helped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing Google Analytics 4&lt;/strong&gt;. Yes, GA4 slows things down. No, removing it is not viable for a site that needs to measure user behavior. Async loading is enough — the perf cost is real but small (~50-100ms), and the observability win is huge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom preloading of individual DLLs&lt;/strong&gt;. Blazor's default boot flow is already tuned. My hand-crafted preloads made things worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two takeaways for anyone doing this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Delete more than you add.&lt;/strong&gt; My biggest wins were removing things — service workers, version-check IIFEs, unused globalization data. Every one of those was "added carefully to fix a specific bug last month" and had turned into dead weight. Audit your legacy carefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The CDN dashboard is code.&lt;/strong&gt; Toggling Brotli in Cloudflare beat every code-level optimization I did. Don't skip the "settings you can change without deploying" — they're often the fastest win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;Site: &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — free Indian income tax calculator, 38 tools, Blazor WASM on Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;Mobile Core Web Vitals: all Good.&lt;br&gt;&lt;br&gt;
Framework: unchanged. Still Blazor.&lt;br&gt;&lt;br&gt;
Total code changed: ~15 lines across 6 commits.&lt;/p&gt;

&lt;p&gt;If you're on Blazor WASM and hitting the "just rewrite it" wall from external advice, try the six mechanical fixes above first. Most of them are one-line changes. Blazor is faster than it gets credit for — you just need to stop actively slowing it down.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About me&lt;/strong&gt;: I'm Chetan Sanghani — I build &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc&lt;/a&gt;, a free CA-reviewed Indian tax calculator platform. I write about frontend performance, SEO for JavaScript apps, and building small-team B2C products on .NET. Previous article: &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator is stricter than schema.org — I learned that the hard way over 4 rejections&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google's MathSolver JSON-LD validator is stricter than schema.org. I learned that the hard way over 4 rounds of fixes.</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Thu, 02 Jul 2026 16:10:46 +0000</pubDate>
      <link>https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0</link>
      <guid>https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0</guid>
      <description>&lt;p&gt;Last week, Google Search Console flagged 10 pages on my Indian tax calculator suite with structured-data errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing field &lt;code&gt;usageInfo&lt;/code&gt; — 10 items&lt;/li&gt;
&lt;li&gt;Invalid value in field &lt;code&gt;itemtype&lt;/code&gt; — 10 items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pages all had &lt;code&gt;MathSolver&lt;/code&gt; JSON-LD blocks. I had 38 similar blocks across the site. The natural assumption: I made a schema mistake once, and it propagated to all 38 tools.&lt;/p&gt;

&lt;p&gt;Four commits and 76 files later, I discovered the real problem — and it wasn't a schema mistake at all.&lt;/p&gt;

&lt;p&gt;Here's the full journey, with commit hashes, so you can skip the six hours I spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: what the schema looked like
&lt;/h2&gt;

&lt;p&gt;For context, &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; is a suite of 38 free Indian tax calculators (HRA, Advance Tax, NRI residency, DTAA, RSU vesting, etc.). Every tool page had a &lt;code&gt;MathSolver&lt;/code&gt; JSON-LD block that looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MathSolver"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fixed Deposit (FD) Calculator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/tools/fd-calculator/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mathExpression"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"maturity = principal * (1 + rate/(4*100))^(4*tenure_years)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isAccessibleForFree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en-IN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mainEntityOfPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/tools/fd-calculator/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Organization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SmartTaxCalc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Person"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA Ankitkumar Italiya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ICAI:644575"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/reviewer/ankitkumar-italiya/"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Legislation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Section 80C and Section 194A of the Income Tax Act, 1961"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"legislationJurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Country"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"India"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PropertyValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Applicable Regime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Both regimes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PropertyValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Applicable AYs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"All AYs"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every field maps to a valid schema.org property. &lt;code&gt;citation&lt;/code&gt; on &lt;code&gt;CreativeWork&lt;/code&gt; (which &lt;code&gt;MathSolver&lt;/code&gt; inherits from) legitimately accepts &lt;code&gt;Legislation&lt;/code&gt; or &lt;code&gt;CreativeWork&lt;/code&gt;. &lt;code&gt;additionalProperty&lt;/code&gt; is &lt;code&gt;PropertyValue&lt;/code&gt;. &lt;code&gt;spatialCoverage&lt;/code&gt;/&lt;code&gt;legislationJurisdiction&lt;/code&gt; on &lt;code&gt;Country&lt;/code&gt; — all valid per &lt;a href="https://schema.org/MathSolver" rel="noopener noreferrer"&gt;schema.org's own docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But Google's validator disagreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: Fix the obvious (commit &lt;code&gt;32d1a24&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis&lt;/strong&gt;: The GSC error strings gave me two things to fix.&lt;/p&gt;

&lt;p&gt;For "Missing field usageInfo" — Google's &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/math-solvers" rel="noopener noreferrer"&gt;MathSolver docs&lt;/a&gt; mark &lt;code&gt;usageInfo&lt;/code&gt; as required. I hadn't included it. Easy fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+  "usageInfo": "https://smarttaxcalc.in/methodology/",
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For "Invalid value in field itemtype" — I assumed the &lt;code&gt;citation.@type: Legislation&lt;/code&gt; was the problem. &lt;code&gt;Legislation&lt;/code&gt; is more specific than &lt;code&gt;CreativeWork&lt;/code&gt;; maybe Google's rich-result validator only accepts the broader type in citation slots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "citation": {
&lt;span class="gd"&gt;-    "@type": "Legislation",
&lt;/span&gt;&lt;span class="gi"&gt;+    "@type": "CreativeWork",
&lt;/span&gt;     "name": "Section 80C and Section 194A of the Income Tax Act, 1961",
&lt;span class="gd"&gt;-    "legislationJurisdiction": {
&lt;/span&gt;&lt;span class="gi"&gt;+    "spatialCoverage": {
&lt;/span&gt;       "@type": "Country",
       "name": "India"
     }
   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied to all 38 razor files + 38 static HTML mirrors. 76 files, one commit. Deployed. &lt;strong&gt;Rich Results Test result: still "1 invalid item detected."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reasonable step, wrong root cause. Time to peel the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Strip the "obviously invalid on MathSolver" properties (commit &lt;code&gt;6311067&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Second hypothesis: some of the fields I kept aren't valid on &lt;code&gt;MathSolver&lt;/code&gt; at all, even though they were valid schema.org somewhere.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;additionalProperty&lt;/code&gt; — checking schema.org more carefully — is defined on &lt;code&gt;Place&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;QualitativeValue&lt;/code&gt;, &lt;code&gt;QuantitativeValue&lt;/code&gt;, and &lt;code&gt;StatisticalPopulation&lt;/code&gt;. &lt;strong&gt;Not &lt;code&gt;CreativeWork&lt;/code&gt;.&lt;/strong&gt; So &lt;code&gt;MathSolver&lt;/code&gt; (which extends &lt;code&gt;CreativeWork&lt;/code&gt;) shouldn't have &lt;code&gt;additionalProperty&lt;/code&gt; even if I want it there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;citation.spatialCoverage: Country&lt;/code&gt; — the &lt;code&gt;Country&lt;/code&gt; subtype felt exotic; Google's stricter validator might reject it in favor of the plain &lt;code&gt;Place&lt;/code&gt; type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  "additionalProperty": [
-    { "@type": "PropertyValue", "name": "Applicable Regime", "value": "Both regimes" },
-    { "@type": "PropertyValue", "name": "Applicable AYs", "value": "All AYs" }
-  ]
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;   "citation": {
     "@type": "CreativeWork",
     "name": "...",
&lt;span class="gd"&gt;-    "spatialCoverage": {
-      "@type": "Country",
-      "name": "India"
-    }
&lt;/span&gt;   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;76 files again. Deploy. &lt;strong&gt;Rich Results Test result: still "1 invalid item detected."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point I was staring at a &lt;code&gt;MathSolver&lt;/code&gt; block with only &lt;code&gt;publisher.Organization&lt;/code&gt;, &lt;code&gt;reviewedBy.Person&lt;/code&gt;, and &lt;code&gt;citation.CreativeWork&lt;/code&gt; as nested types. The same page had a &lt;code&gt;WebApplication&lt;/code&gt; block validating fine with &lt;code&gt;Organization&lt;/code&gt; + &lt;code&gt;Offer&lt;/code&gt; nested. So &lt;code&gt;Organization&lt;/code&gt;/&lt;code&gt;Person&lt;/code&gt; clearly weren't the problem.&lt;/p&gt;

&lt;p&gt;By elimination, the offender had to be &lt;code&gt;CreativeWork&lt;/code&gt; in citation. Or the &lt;code&gt;MathSolver&lt;/code&gt; @type itself. But I hadn't proven the latter yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: Make citation a plain text string (commit &lt;code&gt;e642939&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Third hypothesis: any nested &lt;code&gt;@type&lt;/code&gt; at all inside &lt;code&gt;MathSolver&lt;/code&gt;'s &lt;code&gt;citation&lt;/code&gt; gets rejected. schema.org allows &lt;code&gt;citation&lt;/code&gt; to accept &lt;code&gt;Text&lt;/code&gt; OR &lt;code&gt;CreativeWork&lt;/code&gt; — switching to Text kills the last nested @type without losing information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  "citation": {
-    "@type": "CreativeWork",
-    "name": "Section 80C and Section 194A of the Income Tax Act, 1961",
-    "alternateName": "..."
-  }
&lt;/span&gt;&lt;span class="gi"&gt;+  "citation": "Section 80C and Section 194A of the Income Tax Act, 1961"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy. Rich Results Test. &lt;strong&gt;Same error. "Invalid value in field itemtype."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now the parsed MathSolver had only &lt;code&gt;publisher.Organization&lt;/code&gt; + &lt;code&gt;reviewedBy.Person&lt;/code&gt; as nested types — both of which validated fine on the same page's &lt;code&gt;WebApplication&lt;/code&gt; block, and both of which are standard &lt;code&gt;CreativeWork&lt;/code&gt;-inherited properties.&lt;/p&gt;

&lt;p&gt;If a completely spec-clean &lt;code&gt;MathSolver&lt;/code&gt; with only unambiguous nested types still fails validation, the problem isn't the schema. It's the &lt;code&gt;MathSolver&lt;/code&gt; type itself in this context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 4: Remove MathSolver entirely (commit &lt;code&gt;64a86db&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;I re-read Google's MathSolver documentation with fresh eyes. The doc opens with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solver structured data helps users find math solver services in Google Search results."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solvers help users solve math problems by generating step-by-step solutions..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google MathSolver is a feature-gated schema. It's for &lt;a href="https://www.wolframalpha.com/" rel="noopener noreferrer"&gt;Wolfram Alpha&lt;/a&gt;, &lt;a href="https://photomath.com/" rel="noopener noreferrer"&gt;Photomath&lt;/a&gt;, &lt;a href="https://www.symbolab.com/" rel="noopener noreferrer"&gt;Symbolab&lt;/a&gt; — educational platforms that solve math problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tax calculator suite is not a math solver, no matter how many equations we run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's validator wasn't rejecting a specific field. It was rejecting the eligibility of the entire schema for our use case. The "Invalid value in field itemtype" error was Google's way of saying "this schema doesn't apply here, please stop." I was fighting a feature gate, not a validation bug.&lt;/p&gt;

&lt;p&gt;Removed the &lt;code&gt;MathSolver&lt;/code&gt; block from all 38 tool razors and 38 static mirrors. 1,861 lines deleted across 76 files.&lt;/p&gt;

&lt;p&gt;Rich Results Test result: 0 MathSolver items detected. No error. WebApplication + BreadcrumbList + HowTo all still valid, still eligible for their respective rich results.&lt;/p&gt;

&lt;p&gt;The GSC error will clear as Google re-crawls the pages over the next 3-7 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that would have saved me 6 hours
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the target audience paragraph of any Google structured-data doc before rolling it out at scale.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's structured-data docs almost always open with a "who this is for" statement. For MathSolver:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solver structured data helps users find math solver services in Google Search results."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "math-related content." Not "sites that display formulas." &lt;strong&gt;Math solver &lt;em&gt;services&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I skipped that sentence because I was building tax calculators — of course they involve math. Six hours later I learned that Google's definition of "math solver" is narrower than mine, and its validator enforces the definition rigidly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two practical takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Feature-gated schemas exist. Detect them early.
&lt;/h3&gt;

&lt;p&gt;Not every schema Google documents is available to every publisher. &lt;code&gt;MathSolver&lt;/code&gt;, &lt;code&gt;EmployerAggregateRating&lt;/code&gt;, &lt;code&gt;JobPosting&lt;/code&gt;, &lt;code&gt;DatasetProperty&lt;/code&gt; — these get feature-gated to specific verticals. Even a valid schema fails validation if you're the wrong publisher.&lt;/p&gt;

&lt;p&gt;Look for these signals BEFORE implementing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs open with "helps users find [specific type of service]"&lt;/li&gt;
&lt;li&gt;Named examples in the docs are all one narrow vertical&lt;/li&gt;
&lt;li&gt;Google's rich result gallery for that type shows only 3-5 major players&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those signal apply, validate ONE page in Rich Results Test &lt;strong&gt;before&lt;/strong&gt; rolling out the schema to your entire site.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. "Invalid value in field itemtype" is Google's catch-all rejection.
&lt;/h3&gt;

&lt;p&gt;The error name suggests something specific — a particular &lt;code&gt;@type&lt;/code&gt; value in a particular slot. In practice, Google throws this error whenever &lt;em&gt;anything&lt;/em&gt; about your schema doesn't match its narrower-than-schema.org expectation.&lt;/p&gt;

&lt;p&gt;If you see this error and you're SURE your schema is spec-clean, the answer isn't more schema polish. The answer is: are you using this schema type in a context Google intended it for?&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-mortem: what I'd change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Locked into a guideline doc&lt;/strong&gt; (&lt;a href="https://smarttaxcalc.in/" rel="noopener noreferrer"&gt;&lt;code&gt;docs/guidelines/content-and-integrity.md&lt;/code&gt;&lt;/a&gt; in my repo) — never add MathSolver JSON-LD on this site again. Also captured the anti-pattern for future contributors: validate ONE test page in Rich Results Test before rolling any new schema type across all 38 tools.&lt;/p&gt;

&lt;p&gt;If you're implementing structured data for a niche use case, budget the "test one page first" step. It's the difference between one file changed and 76 files changed four times in a row.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — 38 free Indian tax calculators, editorially reviewed by CA Ankitkumar Italiya (ICAI M-644575). Feedback welcome — especially if you've hit similar feature-gate walls with other Google structured data types.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>google</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
