Why Your React SPA Isn't Ranking — and What We Did About Ours

Single-page React apps have a structural SEO problem: one URL, one title, and content a crawler has to execute JavaScript to see. Here's how we rebuilt our own site to fix it.

Our own website had a problem we’d have flagged immediately on a client project: it was a single-page React app pretending to be a website.

The nav had five items — Services, Process, Projects, Reviews, Tech. Every one of them was an anchor that scrolled you down the same document. There was exactly one URL, one <title>, one meta description, and one entry in a sitemap that didn’t exist. From Google’s perspective, a company offering a dozen distinct services had published a single page about all of them at once.

This is worth writing up because it’s the most common SEO failure we see in modern front-end codebases, and almost none of it is about content quality.

The three structural problems

One URL cannot rank for many things. Search engines rank pages, not sections. A page trying to cover web development, mobile apps, AI integration, and e-commerce competes against pages dedicated entirely to each one — and loses to all of them. Hash fragments like /#services don’t create separate pages; they’re the same document with a scroll position.

Metadata can only describe one page. In a client-rendered SPA, the <title>, description, canonical URL, and Open Graph tags live in a single index.html shell. Libraries like react-helmet can rewrite them after hydration, but you’re then depending on the crawler executing your JavaScript and re-reading the head — which is a bet, not a guarantee.

Content behind JavaScript is content at risk. Google renders JavaScript, but rendering is a second pass with its own queue and budget. Other crawlers are less generous, and AI answer engines vary widely. Anything that requires JS execution to become visible is content you’re hoping gets seen rather than knowing does.

There’s a subtler version of this that’s easy to miss. Our FAQ used a Radix accordion. Radix, like most accordion components, removes the content of collapsed items from the DOM entirely. Every one of our answers — the exact question-and-answer content most likely to get surfaced in an AI Overview — was invisible until a human clicked. We’d shipped an FAQ that no crawler could read.

What we changed

We rebuilt the site as a static, multi-page site on Astro. The specific decisions mattered more than the framework choice:

Real routes, generated from data. Services and case studies now live in two TypeScript files. Each entry generates its own page, its own metadata, its own structured data, and its own sitemap entry. We went from 1 indexable URL to 29 — twelve service pages, eleven case studies, plus the hubs, contact, and blog. Adding a thirteenth service is a data entry, not a new page build.

Metadata per page, rendered server-side. Every page has its own title, description, and canonical URL written into the static HTML at build time. Nothing depends on the client.

JSON-LD in the initial response. Organization and WebSite schema site-wide, plus Service, CreativeWork, FAQPage, BreadcrumbList, and ItemList where they apply — all rendered as static markup rather than injected by a useEffect. This matters more for AI answer engines than for classic ranking: structured, extractable facts are what those systems lean on to resolve who does what.

Native <details> instead of an accordion. The FAQ now uses the browser’s own disclosure element. Collapsed answers stay in the DOM, styled shut rather than removed. It’s less code, needs no JavaScript, and it’s readable by everything.

Zero JavaScript on pages that don’t need it. 28 of our 29 pages now ship no JS bundle at all. Only the contact page loads React, because it’s the one page with a genuinely interactive form. Everything else — navigation, the FAQ, animations — is HTML and CSS. Our navigation needs about fifteen lines of inline script for a scroll state and a mobile menu toggle; loading a 140KB framework for that was pure waste.

What we’d tell you before you migrate

A framework swap is not an SEO strategy. It removes a ceiling. If your content doesn’t answer what people are actually searching for, static rendering won’t save it. We rebuilt the architecture and wrote real service pages, because either one alone would have been half a solution.

Not every React app has this problem. If you’re building a logged-in product dashboard, none of this applies — that content shouldn’t be indexed anyway. This is about public, marketing-facing surfaces. Plenty of teams correctly use a static site for marketing and a SPA for the app behind the login.

Next.js is a fine answer too. We chose Astro because our marketing site has almost no interactivity and we wanted zero JS as the default rather than something to opt into. If your site needs substantial client-side behaviour throughout, that calculus changes.

The cheap wins come first. Before any migration, we added structured data and an FAQ to the old SPA. Both took a fraction of the effort and neither required an architecture decision. Do those first — they might be enough.

The honest summary

We spent this effort on our own site because we sell SEO and GEO as services, and shipping a site with these problems while offering to fix them for other people would have been indefensible.

If your marketing site is a client-rendered SPA and you’re wondering why it doesn’t rank, the answer usually isn’t your content. It’s that you’ve published one page and asked it to do the work of thirty.

Let's collaborate

Ready to build something remarkable?

Share your idea — even a rough one. We'll get back to you within 24 hours with a plan.

No commitment needed · Free 30-min strategy session