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.
| Fix | Why |
|---|---|
| Real HTML for each public URL | Crawlers and link previews see content straight away |
| Unique title, description, canonical per route | Each page can rank and be shared on its own |
| A real path per view and a sitemap | Every 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.
Paste the HTML from View Page Source to check what a crawler receives.
| Approach | How it works | Good for | Cost |
|---|---|---|---|
| Server-side rendering (SSR) | The server renders each request to HTML, then React hydrates it | Pages that change per request or per user | Needs a server; more moving parts |
| Static generation (SSG) | Pages are rendered to HTML at build time | Marketing pages, docs, blogs | Rebuild to update content |
| Build-time prerendering of an existing SPA | A script loads each route in a headless browser at build time and saves the HTML | An existing client-only app you do not want to rewrite | Build gets slower as routes grow |
| Client-side only | The server sends an empty shell; JavaScript builds the page | Logged-in app screens that should not be indexed | Public pages rely on Google rendering them later |
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>
{/* ... */}
</>
);
}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>
</>
);
}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 /> },
]);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>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>
</>
);
}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>
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.
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.
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.
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.
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.
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.