The SEO Cost of a React SPA, Measured on My Own Site
I audited iandolan.com and found one title tag across fifteen pages, every URL returning 200, and nothing an AI crawler could read. Here is the full list.
Most articles about single-page app SEO are written in the abstract. This one is not. In July 2026 I audited my own site, found it comprehensively broken, and rebuilt it. Everything below is what was actually wrong, with the numbers.
It is an uncomfortable thing to publish. It is also the most useful version of this article I could write, because I can show the specifics rather than gesture at them.
What a client-rendered SPA actually serves
The HTML document was 1,791 bytes. All of it was this shape:
<div id="root"></div>
Every word of copy on the site was painted by a 444KB JavaScript bundle after that document arrived. To a browser, fine. To anything that does not execute JavaScript, the site was blank.
That distinction matters more every month. Google renders JavaScript, but on a delayed second pass with no guarantee of completeness. Bing does it poorly. GPTBot, ClaudeBot and PerplexityBot largely do not do it at all.
So a site can rank acceptably on Google and be completely invisible to the systems an increasing share of buyers now ask first.
One title tag. Fifteen pages.
This was the finding that embarrassed me most.
Every route served <title>Ian Dolan Consulting</title>. Not similar titles.
The same one, fifteen times. The bundle contained no react-helmet, no
document.title assignment, no meta management of any kind.
No meta descriptions anywhere. No canonical tags. No Open Graph. No structured
data. An opengraph.jpg sat on the server referenced by nothing, so every LinkedIn
share rendered as a bare link.
The title tag is still the strongest on-page signal there is. It was doing nothing on fourteen of fifteen pages.
Every URL returned 200, including the ones that did not exist
The nginx config had the standard SPA line:
try_files $uri $uri/ /index.html;
That fallback is what makes SPAs work with client-side routing. It also means literally any path returns HTTP 200 with the app shell. The app renders a “404 Page Not Found” component, but the status code says success.
Google calls these soft 404s. The practical effect is an infinite crawlable URL space where nothing can ever be reported as missing.
It gets worse with history. The old WordPress site’s URLs — /blog-new/,
/tag/seo/, /category/marketing/ — were still in Google’s index and still
returning 200. Any link equity pointing at them was being discarded rather than
redirected. /wp-login.php returned 200 as well, which is its own kind of funny.
The performance numbers
No compression at all. Not misconfigured — absent:
| Asset | Served |
|---|---|
| JS bundle | 444,178 bytes, uncompressed |
| CSS | 115,145 bytes, uncompressed |
| Hero image | 901KB PNG, displayed at 570px |
No content-encoding header on any of it. gzip alone would have cut roughly 560KB
to about 140KB. No cache headers on content-hashed assets that are safe to cache
for a year.
The logo was a 2000px JPEG rendered into 40px, 48px and 36px slots.
The subtle ones
These are the findings I would have missed without looking properly.
Animated counters starting at zero. The homepage had stat counters that
animated up on scroll: 200+ clients, 35+ industries, 15+ years. Real numbers, in
the bundle. But the initial DOM contained 0. Anything that does not scroll — every
crawler, every scraper — captured “0+ Clients Served” and “0X Average ROI”.
Inverted heading hierarchy. Decorative eyebrow labels were marked up as <h2>
while the actual section headings underneath were <h3>. The kicker outranked the
real heading in the document outline, sitewide.
maximum-scale=1 in the viewport meta, blocking pinch-zoom. A WCAG 1.4.4
failure.
A stale bundle backup at /assets/index-*.js.bak-precopy, publicly
downloadable, 444KB of old application source.
www and apex both served 200 with no redirect and no canonical between them.
http:// returned 404 rather than redirecting, because the router only bound the
https entrypoint.
What fixing it involved
I rebuilt on Astro with static output. The comparison:
| Before | After | |
|---|---|---|
| Unique titles | 1 across 15 URLs | 31, all unique |
| Meta descriptions | 0 | every page |
| Structured data | none | 3–6 JSON-LD blocks per page |
| JS shipped | 444KB | 0 |
| Homepage payload | ~561KB, uncompressed | ~8KB gzipped |
| Hero image | 901KB PNG | 28KB WebP |
| Missing URL | HTTP 200 | HTTP 404 |
The nginx line that matters:
try_files $uri $uri.html $uri/index.html =404;
No index.html fallback. That single change is the difference between soft 404s
everywhere and a site that can tell the truth about what exists.
When a SPA is still the right call
This is not an argument that single-page apps are bad. They are the right architecture for applications: dashboards, editors, anything behind a login where search visibility is irrelevant.
The mistake is using an application architecture for a marketing site, where being readable by machines is most of the job.
If you already have one, the two questions worth answering today: what does
curl actually return for your pages, and does a URL that does not exist return
404 or 200? Both take a minute and both are usually more revealing than a
rankings report.
If the answer is uncomfortable, web development and technical SEO are the two services that address it, and AI visibility covers the part where crawlers that do not run JavaScript are increasingly the ones that matter.