Single Page Application SEO: A Practical Guide

Single page apps often send search engines an almost empty HTML shell and build the page with JavaScript later. Google can render JavaScript, but it happens after a delay and other crawlers may not. Fix it by serving real HTML per URL through server rendering, static generation or prerendering, with a unique title, description and canonical on every route.

The three fixes that matter most
FixWhy
Real HTML for each public URLCrawlers and link previews see content straight away
Unique title, description, canonical per routeEach page can rank and be shared on its own
A real path per view and a sitemapEvery page can be discovered and indexed

Updated

This site is itself a React single page app that prerenders every public page at build time, so these are the checks we run on our own pages. Paste a page's source into the checker to see what a crawler gets before any JavaScript runs.

Need the tags written for you? Try the meta tag generator.

Page source checker

Paste the HTML from View Page Source to check what a crawler receives.

SPA SEO checklist

SSR, static generation or prerendering

Ways to give crawlers real HTML
ApproachHow it worksGood forCost
Server-side rendering (SSR)The server renders each request to HTML, then React hydrates itPages that change per request or per userNeeds a server; more moving parts
Static generation (SSG)Pages are rendered to HTML at build timeMarketing pages, docs, blogsRebuild to update content
Build-time prerendering of an existing SPAA script loads each route in a headless browser at build time and saves the HTMLAn existing client-only app you do not want to rewriteBuild gets slower as routes grow
Client-side onlyThe server sends an empty shell; JavaScript builds the pageLogged-in app screens that should not be indexedPublic pages rely on Google rendering them later

Fixes with code

Per-route title and description in React 19

React 19 lets any component render title, meta and link tags; React moves them into the document head. Each route sets its own.

export function PricingPage() {
  return (
    <>
      <title>Pricing | Harbour Cafe Catering</title>
      <meta name="description" content="Catering packages from $12 a head, with delivery across the harbour district." />
      <link rel="canonical" href="https://example.com/pricing" />
      <h1>Catering pricing</h1>
      {/* ... */}
    </>
  );
}

The same with react-helmet-async (React 18 and earlier)

Wrap the app once in HelmetProvider, then set tags per page. The async version is safe for server rendering and prerendering.

import { Helmet, HelmetProvider } from "react-helmet-async";

export function App() {
  return <HelmetProvider><Routes /></HelmetProvider>;
}

export function PricingPage() {
  return (
    <>
      <Helmet>
        <title>Pricing | Harbour Cafe Catering</title>
        <meta name="description" content="Catering packages from $12 a head." />
        <link rel="canonical" href="https://example.com/pricing" />
      </Helmet>
      <h1>Catering pricing</h1>
    </>
  );
}

Real URLs, not hash fragments

Google ignores everything after a # when deciding what a URL is. Use the History API (BrowserRouter, createBrowserRouter) so each view has its own path.

// Avoid: https://example.com/#/pricing
// Use:   https://example.com/pricing
import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  { path: "/", element: <Home /> },
  { path: "/pricing", element: <PricingPage /> },
  { path: "*", element: <NotFound /> },
]);

Crawlable links

Crawlers follow a elements with an href. A click handler on a div is invisible to them. Router Link components render a real anchor.

// Crawlable
<Link to="/pricing">Pricing</Link>      {/* renders <a href="/pricing"> */}

// Not crawlable
<div onClick={() => navigate("/pricing")}>Pricing</div>

Avoid soft 404s for unknown routes

A client-only app answers every URL with status 200, so a missing page looks like a real one. Add a noindex to the not-found view, or serve a real 404 from the server or the prerender step.

export function NotFound() {
  return (
    <>
      <title>Page not found | Harbour Cafe</title>
      <meta name="robots" content="noindex" />
      <h1>We could not find that page</h1>
      <a href="/">Go to the home page</a>
    </>
  );
}

A sitemap of every public route

List each public URL once, with its canonical address. Generate it from the same route list your app uses, so they never drift apart.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://example.com/</loc><lastmod>2026-10-01</lastmod></url>
  <url><loc>https://example.com/pricing</loc><lastmod>2026-09-12</lastmod></url>
  <url><loc>https://example.com/menu</loc><lastmod>2026-09-30</lastmod></url>
</urlset>

Testing like Google

In Search Console, URL Inspection shows whether a page is indexed and, with Test live URL, the HTML Google rendered and any resources it could not load. Compare that HTML with your visible page: headings, main text and links should all be there.

How it works

  1. Open a page, choose View Page Source, and copy all of it.
  2. Paste it into the checker below.
  3. Read which basics are present in the raw HTML and which are missing.
  4. Fix the gaps with one of the approaches and code samples on this page.
  5. Confirm with Search Console's URL Inspection, which shows the HTML Google rendered.

Tips

Frequently asked questions

Can Google index a React single page app?

Yes. Google renders JavaScript, but pages wait in a render queue and anything that fails to load is missed. Other search engines and link-preview bots render less or not at all, so serving HTML is the safer choice.

Do I need Next.js for React SEO?

No. Next.js makes server rendering and static generation easy, but a Vite or Create React App style SPA can prerender its public routes at build time and get the same crawlable HTML.

Is react-helmet still needed in React 19?

Not for basic tags. React 19 renders title, meta and link elements from any component and moves them into the head. react-helmet-async remains useful on React 18 and earlier.

Is prerendering cloaking?

Not when crawlers and people get the same content. Prerendering only saves the page your users would see anyway. It becomes cloaking if the HTML served to bots says something different.

Related: Meta tag generator, XML sitemap generator, robots.txt tester, React Router examples, All free tools.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap