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.

· 9 min read

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.

Where this leads

Related services

Web Development

Sites built to perform

SEO

Dominate search rankings

AI Visibility

Get cited by ChatGPT and AI Overviews

Want this looked at properly?

Book a free discovery call. I'll come having already looked at your site, and you keep the findings either way.

Book a Free Discovery Call