React SPA SEO: How to Get Google to Index Client-Side Apps
Quick Answer
Google can index a React SPA, but you have to hand it real HTML. Generate a static HTML file for every route at build time: copy the built index.html, inject each route's title, meta description, and canonical tag, and add semantic content crawlers can read without JavaScript. React hydrates on top for users. We did this on our own Vite site and Google crawled all 8 pages within 3 days.

The Problem
You built a React SPA with Vite or Create React App. You submitted your sitemap to Google Search Console. Google discovered your URLs but shows "Last crawled: N/A" for every page. Your site isn't ranking because Google sees an empty HTML shell.
Maybe you're seeing the other flavour of the same problem: pages stuck on "Discovered, currently not indexed" for weeks. Same cause, same fix.
This is the classic SPA crawlability problem. When Googlebot fetches your pages, it gets:
<div id="root"></div> <script src="/assets/index.js"></script>
Zero content. While Googlebot can render JavaScript, it deprioritises JS-rendered content and often fails to index SPAs properly.
How Googlebot Actually Handles JavaScript
Google indexes in two waves. The first wave reads the raw HTML your server returns. The second wave puts your page in a rendering queue, executes the JavaScript, and indexes whatever appears. That queue can take days or weeks. Low-authority sites wait longest.
If your raw HTML is an empty div, you're betting your entire search presence on the second wave. Sometimes it works. Often it doesn't.
It gets worse outside Google. Facebook, LinkedIn, and Twitter scrapers do not run JavaScript at all, so your share previews show generic homepage tags. Most AI crawlers behave the same way. If ChatGPT can't read your pages, it can't recommend your business. The fix for all of it is the same: put real content in the initial HTML response.
Why This Happens
1. Empty HTML Shell
Your build outputs a single index.html with no content. Every route serves the same empty file.
2. Client-Side Meta Tags
Tools like react-helmet only inject meta tags after JavaScript executes. Crawlers that don't fully render JS see generic homepage meta tags for every URL.
3. Loading Screens Block Crawlers
If you have a loading screen that uses sessionStorage, crawlers hit it every time because they don't persist session data.
Your Options: Prerendering, SSR, or a Paid Service
Before writing any code, pick the right approach for your site. There are three realistic options:
BUILD-TIME PRERENDERING (THIS GUIDE)
A script runs after your build and writes one HTML file per route. No new framework, no server, free to host on any static CDN. Best when your routes are known at build time: marketing pages, blogs, portfolios, case studies. This covers most business sites.
SERVER-SIDE RENDERING (NEXT.JS, REMIX)
The server builds HTML on every request. Worth it when content is dynamic or user-specific: dashboards, marketplaces, sites with thousands of database-driven pages. The cost is a full framework migration and a server to run and pay for.
PRERENDER SERVICES (PRERENDER.IO AND SIMILAR)
Middleware detects bots and serves them a rendered snapshot of your page. Quick to bolt on, but it adds a monthly bill and a third-party dependency between Google and your content. Fine as a stopgap. We wouldn't build on it long term.
For a Vite SPA with a fixed set of routes, build-time prerendering is the cheapest and most durable fix. Here's how to do it.
The Solution: Build-Time Prerendering
Generate static HTML files for each route at build time. Crawlers get real content immediately, then React hydrates for interactivity.
Step 1: Create a Prerender Script
Create scripts/prerender.js:
import fs from 'fs'
import path from 'path'
const routes = [
{ path: '/', title: 'Home', description: '...' },
{ path: '/about', title: 'About', description: '...' },
// Add all your routes
]
function generateHtml(baseHtml, route) {
return baseHtml
.replace(/<title>[^<]*<\/title>/, `<title>${route.title}</title>`)
.replace(/<meta name="description" content="[^"]*" \/>/,
`<meta name="description" content="${route.description}" />`)
}
const baseHtml = fs.readFileSync('dist/index.html', 'utf-8')
routes.forEach(route => {
const dir = path.join('dist', route.path)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'index.html'),
generateHtml(baseHtml, route)
)
})The script reads your built index.html once, then writes a copy per route with the right title and description swapped in. Add a canonical tag replacement too if your base template includes one. Static hosts like Netlify, Vercel, and Cloudflare Pages will serve /about/index.html for requests to /about automatically.
Step 2: Update Build Script
In package.json:
"scripts": {
"build": "vite build && node scripts/prerender.js"
}Now every deploy regenerates the static files. There is nothing to remember and nothing to pay for.
Step 3: Add Semantic Content
Include hidden semantic HTML in each route's prerendered file so crawlers see real content:
<div id="root">
<div style="position:absolute;left:-9999px" aria-hidden="true">
<h1>Your Page Title</h1>
<p>Page description and content...</p>
<a href="/link">Internal links</a>
</div>
</div>Keep this honest. Mirror what the rendered page actually says, in a sentence or three per section, plus your internal links. Stuffing it with keywords the visible page doesn't contain is cloaking, and Google penalises that.

Fix Loading Screens
Detect bot user agents and bypass the loading screen:
const isBot = /bot|crawl|spider|googlebot/i.test(navigator.userAgent)
if (isBot || sessionStorage.getItem('loaded')) {
onLoadComplete()
return
}Crawlers start every fetch with a clean session, so a "show the intro once" check never passes for them. Without this bypass, Google's renderer can spend its whole render budget staring at your loading animation.
Add Noscript Fallback
In your index.html:
<noscript>
<h1>Your Site Name</h1>
<p>Site description...</p>
<nav>
<a href="/">Home</a> |
<a href="/about">About</a>
</nav>
<p>Please enable JavaScript to view the full site.</p>
</noscript>This is a small win for crawlers and a real win for accessibility. It also gives users on flaky connections something other than a blank screen while your bundle loads.
Verify It Actually Worked
Don't trust the deploy. Check it:
- Run
curl https://yoursite.com/aboutand confirm the correct title and description are in the response. If curl sees it, every crawler sees it. - In Search Console, use URL Inspection on each page, then "View crawled page" to see the exact HTML Google received.
- Hit "Request indexing" for your most important pages. It jumps the queue.
- Confirm your sitemap.xml lists every prerendered route and resubmit it.
- Paste a URL into a social share debugger to confirm the per-page OG tags come through.
Then wait. Recrawls took under a week for us, but Google moves at its own pace, especially for newer domains.
Results
After implementing build-time prerendering on our portfolio site:
- Google crawled all 8 pages within 3 days
- "Last crawled: N/A" changed to actual dates
- Each page now has correct title and description in search results
- Organic traffic increased 340% in first month
The whole fix was one script and a build command change. No framework migration, no monthly service, no server bill. We shipped the same setup, plus structured data and OG tags, on our own site in a day. The case study has the full breakdown.
Key Takeaways
- SPAs serve an empty HTML shell. Crawlers need real content in the first response.
- Build-time prerendering fixes this without a framework migration or a monthly bill.
- Generate per-route HTML files with unique titles, descriptions, and semantic content.
- Bypass loading screens for bot user agents. Crawlers never have session data.
- Add a noscript fallback for crawlers and accessibility.
- Verify with curl and Search Console's URL Inspection, then request indexing.
FAQ
Can Google index a React single page app?
Yes, but not reliably. Googlebot can execute JavaScript, but rendering happens in a second wave that can lag days or weeks behind the initial crawl. Some pages never make it through the queue, which is how you end up with "Discovered, currently not indexed". Prerendered HTML removes the gamble because the content is there on the first fetch.
Do I need to switch to Next.js for SEO?
No. If your routes are known at build time (marketing pages, blog posts, case studies), a prerender script gives you the same crawlable HTML without a framework migration. Next.js earns its keep when you have dynamic or user-specific content that must be rendered per request. For a typical business site it is overkill.
What is the difference between prerendering and server-side rendering?
Prerendering generates static HTML once, at build time, and serves the same files to everyone. Server-side rendering builds the HTML on every request. Prerendering is simpler, free to host on a CDN, and fast. SSR handles content that changes per user or per minute. If your pages only change when you deploy, prerendering wins.
Does React Helmet work for SEO?
Only partly. Helmet updates meta tags after JavaScript runs, so Googlebot's renderer eventually sees them. But crawlers that skip JavaScript, including most social media scrapers and many AI crawlers, only see whatever is in your static HTML. Bake the correct tags into each prerendered file and let Helmet keep them in sync client-side.
How long does Google take to index a fixed SPA?
In our case, Google crawled all 8 pages within 3 days of deploying prerendered HTML and requesting indexing through Search Console. Timing varies with your site's crawl budget and authority. New sites can take a few weeks. Use the URL Inspection tool to request indexing for your most important pages first.
Related Reading
GEO and AEO Explained
Once crawlers can see the page, the five checks that decide whether ChatGPT and AI Overviews quote it.
WordPress vs Custom Website
Which one your business actually needs, with honest trade-offs for both.
Do You Need a Custom Website in 2026?
When a template is enough and when custom pays for itself.
Case Study: WastedMyUlt Online
Full technical SEO, prerendering included, shipped in a day.
Case Study: Cre8 Collective
A React site scoring 90+ on Lighthouse across the board.
Not Sure What Google Sees on Your Site?
We'll check your indexing, meta tags, structured data, and page speed, then send you a plain-English report. Free, no obligation, no sales pitch.
Get a Free Website Audit