Skip to content
← Back to the blog
websitessoftware development

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.

By Devonauts Team6 min read
Leer en español →
Close-up of HTML code on a monitor showing a title tag and stylesheet links in the page head

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's BrowserRouter does this).
  • Navigation that isn't a link. Google follows <a href="..."> elements. A div with an onClick that calls navigate() 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 noindex left over from staging, set in the base HTML. Google may skip rendering a page that starts with noindex, so a script that removes it later doesn't help.

What are my options?

ApproachWhat it meansGood forCost
Per-page metadata onlySet title and description from each route on the clientApps where only the home page mattersLow, but still depends on rendering
Prerendering (static generation)Build a real HTML file for each public route at build timeMarketing pages, blog, docs, pricingLow to medium
Server-side renderingThe server builds the HTML on each requestPages that change per request or have many URLs (listings, profiles)Medium to high, needs a server
Dynamic renderingServe a prerendered copy only to botsLegacy setupsGoogle 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:

  1. 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 window during render can break the build.
  2. Move the marketing pages to a static site (Astro, or a separate Next.js project) on the main domain, and serve the app from /app or a subdomain. This is often cleaner than forcing SEO onto an app that was never designed for it.
  3. 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?

  1. 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.
  2. 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.
  3. Use Google Search Console's URL Inspection tool and look at the crawled page and its rendered HTML.
  4. Paste a link into a messaging app and check that the preview card shows that page's title.
  5. 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.

Still stuck with your app?

Run the free health check on your app's URL, or get the $299 App Rescue Audit: a senior engineer reviews the code and sends you a written report in 2 business days.