SEO for React single-page apps: prerender, SSR or both?
Why a React SPA can rank badly, how Google handles JavaScript, and when to use prerendering, server-side rendering or better per-page metadata.
Leer en español →
A React single-page app can rank in Google, but only if every public page returns real HTML with its own title, description and content, either by prerendering those pages at build time or by rendering them on the server. Google does run JavaScript, but it does so in a second pass, and many other crawlers and AI tools never run it at all. If your app sends the same empty <div id="root"> for every URL, you are leaving that work to chance.
This guide explains what a crawler actually sees on a typical SPA built with Vite, Lovable, Bolt or similar tools, how to choose between prerendering and server-side rendering, and how to check the result.
Why does my React app not show up properly in Google?
Open your live site, right-click and choose "View page source" (not the Elements panel, which shows the page after JavaScript ran). On a standard client-rendered React app you will usually see:
- One
<title>that is the same on every page, often the project name. - No meta description, or the same one everywhere.
- An empty root element and a script tag. No headings, no text, no links.
Google's documentation describes three phases for JavaScript pages: crawl, render, index. Pages that return a 200 status are queued for rendering, and rendering can happen seconds or much later. Until it does, Google only has that empty shell. Social previews (the card you see when you paste a link into Slack, LinkedIn or WhatsApp) generally read the raw HTML only, so they show the generic title on every page. The same goes for many AI answer engines' crawlers.
Other common causes in AI-built apps:
- Hash routes like
/#/pricing. Google says it cannot reliably resolve fragment URLs, so use real paths with the History API (React Router'sBrowserRouterdoes this). - Navigation that isn't a link. Google follows
<a href="...">elements. Adivwith anonClickthat callsnavigate()is invisible to a crawler. - Soft 404s. An SPA answers every URL with status 200, including pages that don't exist. Google may index "Page not found" screens as real pages.
- A
noindexleft over from staging, set in the base HTML. Google may skip rendering a page that starts withnoindex, so a script that removes it later doesn't help.
What are my options?
| Approach | What it means | Good for | Cost |
|---|---|---|---|
| Per-page metadata only | Set title and description from each route on the client | Apps where only the home page matters | Low, but still depends on rendering |
| Prerendering (static generation) | Build a real HTML file for each public route at build time | Marketing pages, blog, docs, pricing | Low to medium |
| Server-side rendering | The server builds the HTML on each request | Pages that change per request or have many URLs (listings, profiles) | Medium to high, needs a server |
| Dynamic rendering | Serve a prerendered copy only to bots | Legacy setups | Google calls it a workaround, not a long-term solution |
For most founders the honest answer is: prerender the public pages, keep the logged-in app as a normal SPA. Nobody needs the dashboard behind your login to rank.
How do I add per-page titles and descriptions?
Even if you prerender, each route needs its own metadata. In React 19 you can render <title> and <meta> inside any component and React moves them into the document head:
export default function PricingPage() {
return (
<>
<title>Pricing | Acme</title>
<meta name="description" content="Plans, limits and what each one includes." />
<h1>Pricing</h1>
</>
);
}
Two caveats from the React docs: render only one <title> at a time, and pass it a single string (use a template literal, not text plus a variable). On older React versions a library such as react-helmet-async does the same job.
Add a self-referencing canonical link to each public page, and put it in the HTML rather than changing it with JavaScript later. Google's guidance is that a canonical set by script should match the original one.
How do I prerender a Vite or React Router app?
If you use React Router in framework mode
React Router has a prerender option in react-router.config.ts. You can keep the app as a static SPA (ssr: false) and still produce real HTML for chosen paths:
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
prerender: ["/", "/pricing", "/about", "/blog"],
} satisfies Config;
The build writes one HTML file per listed path. Every other URL falls back to the SPA shell, which your host must serve for unknown paths.
If you have a plain Vite app with BrowserRouter
You have three realistic choices:
- Add a prerender step that loads each public route in a headless browser at build time and saves the resulting HTML. Several Vite plugins do this. It is the smallest change, but test it carefully, because components that read
windowduring render can break the build. - Move the marketing pages to a static site (Astro, or a separate Next.js project) on the main domain, and serve the app from
/appor a subdomain. This is often cleaner than forcing SEO onto an app that was never designed for it. - Migrate to a framework with server rendering such as Next.js or React Router framework mode. This is the biggest change and makes sense when you have many public, data-driven URLs, like public profiles or listings.
What about hosts that rewrite every path to index.html?
Most SPA hosting uses a catch-all rewrite. Make sure your prerendered files are served first and the rewrite only catches what's left. Also return a real 404 for unknown paths, or at least add <meta name="robots" content="noindex"> on your not-found screen.
How do I check that it worked?
- View source on three or four public URLs. You should see the page's own title, description,
<h1>and body text in the raw HTML. - Run
curl -s https://yourdomain.com/pricing | grep -i "<title>". If the output differs per page, crawlers that don't run JavaScript will see it too. - Use Google Search Console's URL Inspection tool and look at the crawled page and its rendered HTML.
- Paste a link into a messaging app and check that the preview card shows that page's title.
- Check the sitemap. It should list every public URL with its real path, and nothing behind your login.
If you also want rich results, see our guide to structured data for a SaaS website. Speed matters as well: a 2 MB JavaScript bundle delays everything, which we cover in Core Web Vitals for your app. If your app was built in Lovable specifically, read why Google isn't indexing your Lovable app.
Quick answers
Does Google index React apps without SSR? Often, yes, because Googlebot renders JavaScript. But it happens later, can fail on errors, and other crawlers and link previews usually don't run JavaScript at all.
Is prerendering the same as cloaking? No. Prerendering serves the same HTML to everyone. Cloaking is showing crawlers different content from users, which Google penalises.
Do I need SSR for my logged-in dashboard? No. Pages behind a login shouldn't be indexed. Keep them client-rendered and mark them noindex or block them in robots.txt.
Will a sitemap fix an SPA's SEO? A sitemap helps Google find URLs. It doesn't give those URLs any content, so it doesn't replace prerendering.
Need a hand with it?
Prerendering an existing app usually means small changes in many places: routes, components that touch window, hosting rewrites, metadata. If you'd rather not do it alone, our custom software development team can set it up, and maintenance and support keeps it working as the app changes. The free health check shows what's visible from outside today, or write to us with your URL.
For Google's own explanation, see JavaScript SEO basics.


