Making a React SPA crawlable: how we fixed Google's Soft 404s in 30 lines
Google was marking every product page on our React SPA as "Soft 404". No penalty, no error — just quietly refusing to index them. Here's the exact prerender script that fixed it, why it works, and the mistake that made us waste a week before finding it.
Making a React SPA crawlable: how we fixed Google's Soft 404s in 30 lines
For a week, Google Search Console kept showing the same thing on every one of our five product pages:
> **Soft 404** — Page cannot be indexed.
No crawl error. No robots block. No noindex tag. Just a quiet verdict that our React SPA's pages weren't "real" enough to earn a spot in the index. If you ship a Vite/CRA/Next-static SPA and market it through /product-a, /product-b, /product-c, this post is the fix.
The symptom
Every product route (`/chromawrite`, `/markdownpdf`, `/agentsafety`, `/resonance`, `/ghostsignature`) rendered fine in the browser. But `view-source:` on any of them showed the same 300-byte HTML shell — the Vite `index.html` with a `<div id="root"></div>` and no content.
Googlebot renders JavaScript, but it also does a cheap first pass on the raw HTML. When every URL returns *the same* empty shell, Google's heuristics assume the page has no unique content and files it as Soft 404. It's not a crawl failure — it's a "why bother indexing five copies of nothing" verdict.
The fix in one sentence
At build time, for each public route, write a copy of `dist/index.html` with a unique `<title>`, `<meta description>`, canonical URL, and one paragraph of static content. That's it.
You don't need SSR. You don't need a rewrite to Next.js. You need a 30-line Node script and a `prebuild` hook.
The script
Drop this in `scripts/prerender-public-pages.mjs`:
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const BASE_URL = 'https://fyrnity.com';
const SHELL = readFileSync(resolve('dist/index.html'), 'utf8');
const pages = [
{ path: '/chromawrite', title: 'ChromaWrite — Humanize AI text in 50+ languages',
desc: 'Rewrite AI-generated copy to sound like a human. Free tier, PRO $29, ULTRA $99. Live on RapidAPI.',
lead: 'ChromaWrite is a REST API that rewrites AI-generated text so it reads like a real person wrote it — across 50+ languages.' },
{ path: '/markdownpdf', title: 'MarkdownPDF — Beautiful PDFs from Markdown',
desc: 'Turn Markdown into styled PDFs with themes that don''t look like 2005. Free tier, PRO $29, ULTRA $99.',
lead: 'MarkdownPDF is a themed Markdown-to-PDF API for docs, invoices, and reports.' },
// ...one entry per public route
];
for (const p of pages) {
const url = `${BASE_URL}${p.path}`;
const html = SHELL
.replace(/<title>[^<]*<\/title>/, `<title>${p.title}</title>`)
.replace(/<meta name="description"[^>]*>/, `<meta name="description" content="${p.desc}">`)
.replace(/<\/head>/, `<link rel="canonical" href="${url}"><meta property="og:url" content="${url}"><meta property="og:title" content="${p.title}"></head>`)
.replace(/<div id="root"><\/div>/, `<div id="root"><main hidden><h1>${p.title}</h1><p>${p.lead}</p></main></div>`);
const out = resolve('dist', `.${p.path}`, 'index.html');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html);
}
console.log(`Prerendered ${pages.length} public pages.`);
Wire it into `package.json`:
{
"scripts": {
"postbuild": "node scripts/prerender-public-pages.mjs"
}
}
That's the entire fix.
Why it works
Three things happen at once, and Google needs all three:
1. **Unique `<title>` and `<meta description>` per URL.** These are the two fields Google uses to distinguish "this is a real page" from "this is a duplicate shell." If ten URLs have the same title, they collapse.
2. **A self-referential canonical.** `<link rel="canonical" href="https://you.com/chromawrite">` on `/chromawrite` tells Google *this URL is the source of truth*. Without it, Google guesses — and when the raw HTML is identical to `/`, it often guesses `/` and drops the rest.
3. **A visible content signal in the raw HTML.** The `<main hidden>` block is invisible to users (React hydrates over it) but visible to Googlebot's first pass. One sentence is enough. It's the difference between an empty room and a room with a "yes, this is a real page" sign.
The `hidden` attribute doesn't hurt SEO — Google explicitly counts hidden-but-present content when it's clearly informational, not keyword stuffing. Don't hide 5,000 words of duplicated marketing copy; do hide one honest sentence per page.
The mistake that cost us a week
We first tried to solve this with react-helmet-async and per-route `<Helmet>` blocks. In-browser it looked perfect — the DOM had the right title and description on every route. But `curl https://fyrnity.com/chromawrite` still returned the empty shell, because Helmet mutates the DOM *after* JavaScript runs, and Googlebot's cheap first pass doesn't wait.
Helmet is still worth keeping for dynamic routes (blog posts, user pages) where the title depends on data you can't know at build time. But for your fixed marketing pages — the ones you want indexed — the title has to be in the HTML the server sends. Not the HTML your app renders.
How to check if you have this problem
Open Chrome, hit `Cmd+Option+U` on one of your product pages, and search the source for your product name. If it's not there, Googlebot isn't seeing it either.
Or, from the terminal:
curl -s https://your-site.com/your-product | grep -i "<title>"
If every URL returns the same title, you have the same bug we had.
What happened after the fix
We shipped this on July 22, 2026. We asked GSC for reindexing on the five affected URLs the same day. Nothing to report yet — Google takes 1-3 weeks to revisit and change a Soft 404 verdict, so we're waiting. But the raw HTML is now unique per route, `curl` proves it, and the mistake is documented.
If this saved you a week, we're even. If it didn't, [the API side of our stack lives on RapidAPI](https://rapidapi.com/user/fyrnity) — five small, honest tools, free tier on every one.
Loading interactive experience…