<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Shipping Notes]]></title><description><![CDATA[Shipping Notes]]></description><link>https://thedolceway.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Shipping Notes</title><link>https://thedolceway.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 09:07:46 GMT</lastBuildDate><atom:link href="https://thedolceway.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My SPA served every URL the same 3,780 bytes, and Google believed it]]></title><description><![CDATA[Checked with a Googlebot user agent one morning: every single URL on my site returned the same 3,780-byte shell. Same <title>, zero <h1>, zero body text. The homepage, a blog post and a product page w]]></description><link>https://thedolceway.hashnode.dev/my-spa-served-every-url-the-same-3-780-bytes-and-google-believed-it</link><guid isPermaLink="true">https://thedolceway.hashnode.dev/my-spa-served-every-url-the-same-3-780-bytes-and-google-believed-it</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Moustafa Tarabya]]></dc:creator><pubDate>Tue, 25 Aug 2026 10:07:22 GMT</pubDate><content:encoded><![CDATA[<p>Checked with a Googlebot user agent one morning: every single URL on my site returned the same 3,780-byte shell. Same <code>&lt;title&gt;</code>, zero <code>&lt;h1&gt;</code>, zero body text. The homepage, a blog post and a product page were byte-identical before JavaScript ran.</p>
<p>Search Console agreed with the crawler rather than with me. Of 741 URLs, 116 had earned a single impression in 28 days, and a landing page that had been live for five months was still reported as "URL is unknown to Google".</p>
<p>Here is what I actually learned fixing it, including the two things that cost me the most time.</p>
<h2>Google does render JavaScript. That is not the point.</h2>
<p>The standard reply to this problem is "Googlebot executes JS now, you are fine." It does. Several of my pages were indexed, so rendering clearly happened.</p>
<p>But rendering is a <strong>separate, budgeted queue</strong>. A domain with little authority does not get much of that budget. So the practical question is not "can Google render my page", it is "will Google spend its budget rendering <em>this</em> page, today, before it decides what the page is about".</p>
<p>There is a second problem that has nothing to do with rendering: 741 URLs that are byte-identical before render look like duplicates. You are handing a duplicate-content signal to the crawler and hoping the render queue fixes your first impression.</p>
<h2>What I built, and what I deliberately did not</h2>
<p>I wrote a post-build script that injects a real <code>&lt;head&gt;</code> into each generated HTML file: title, description, canonical, robots, Open Graph, Twitter.</p>
<p><strong>Head only.</strong> I left the body exactly as the SPA shipped it. That was deliberate:</p>
<ul>
<li>No hydration flash.</li>
<li>No risk of a static copy drifting out of sync with what users see.</li>
<li>Nothing that could be read as cloaking, because the static markup is a subset of the rendered markup, not a different page.</li>
</ul>
<p>Every value is read from the same source the React page reads. Where a title is a literal inside a component, the script extracts it from that component's source rather than having me retype it. A number retyped in two places is a number that will disagree with itself eventually.</p>
<h2>Trap 1: react-helmet-async deletes tags you did not mark</h2>
<p>My shell had static <code>&lt;meta name="description"&gt;</code> in <code>index.html</code>. Helmet sets its own on mount. I assumed the later one wins.</p>
<p>What actually happens: on its first commit, Helmet removes every tag carrying <code>data-rh</code> and re-inserts its own. Tags <strong>without</strong> that attribute survive. So the static tag stayed, Helmet's tag was added, and the page shipped two descriptions — with the static one first in the head, which is the one scrapers and crawlers read.</p>
<p>Every blog post on the site was advertising the generic homepage copy.</p>
<p>The fix is to mark the tags Helmet actually emits:</p>
<pre><code class="language-html">&lt;meta data-rh="true" name="description" content="..." /&gt;
</code></pre>
<p>With one sharp edge: only mark tags Helmet will re-add. A <code>data-rh</code> tag that Helmet does not re-insert gets deleted on mount and never comes back. I marked <code>og:image</code> and lost my share image everywhere until I worked out why.</p>
<p>The matching bug in my own script: I wrote the replace as a regex keyed on attribute order, <code>&lt;meta name="description" ...&gt;</code>. The shell writes <code>&lt;meta data-rh="true" name="description" ...&gt;</code>. It silently matched nothing. Match on <em>a tag containing the key</em>, never on attribute order.</p>
<h2>Trap 2: the script read the sitemap, and nothing regenerated the sitemap</h2>
<p>This one cost the most time and had the least to do with SEO.</p>
<p>My prerender script takes its URL list from <code>sitemap.xml</code>. Reasonable: one source of truth for what exists.</p>
<p>But <code>npm run build</code> did not include the sitemap generator. It ran vite, then redirects, then the crawl index, then prerender. The sitemap was generated by a separate command somebody had to remember to run.</p>
<p>So I added a new route, built, deployed, and the page was live and completely absent from the prerendered set. No error. No warning. The build exited 0. The route simply was not in the list, so it was never processed, and it shipped with the generic shell head.</p>
<p>I burned two builds assuming my code was wrong before checking whether the input was stale.</p>
<p><strong>If a build step consumes a generated artifact, that artifact's generator belongs in the same build command.</strong> Otherwise you have an ordering dependency that lives only in somebody's memory, and it fails silently rather than loudly.</p>
<h2>Trap 3: a dead ternary that read as intentional</h2>
<p>I found this while adding static content for a specific route:</p>
<pre><code class="language-js">const key = m ? m[1] : route === '/some-route' ? null : null;
</code></pre>
<p>Both branches return <code>null</code>. It was presumably mid-refactor when someone got interrupted. It looks purposeful enough to skim past in review — there is a route name in it, so it reads like a handled special case.</p>
<p>The effect was that the route fell through to a generic fallback body, which contained none of the terms the page existed to rank for. The page had been quietly pointless for weeks.</p>
<p>Grep your codebase for <code>? null : null</code>. I will wait.</p>
<h2>What I would tell myself at the start</h2>
<ul>
<li>Verify with <code>curl</code> and a crawler user agent, not with your browser. Your browser runs the JavaScript. That is exactly the thing you are trying to see past.</li>
<li>Static head, dynamic body, always from one source. Two hand-maintained copies of the same string are a future contradiction.</li>
<li>When something silently does nothing, check the input before you debug the logic.</li>
<li><code>data-rh</code> is not decoration. It decides which tags survive.</li>
</ul>
<p>If you want to see the output, it is running on <a href="https://cvbooster.ai">CVBooster</a>, a resume builder I run. Every page announces what it is before a line of JavaScript executes, which is all this work was ever about.</p>
]]></content:encoded></item></channel></rss>