React SEO: making a React app readable by Google and by AI crawlers

React is not bad for SEO, but React on its own renders in the browser, so a plain Vite app sends crawlers an empty page until JavaScript runs. Google runs it in a delayed second pass and most AI crawlers never do. Render your public routes earlier, with a framework or a prerender step, and give each route its own title, description and canonical, which React 19 lets a component do directly.

By , founder of Porteur · Updated 23 September 2026 · Markdown

Which React setup you have

Everything depends on where your HTML is produced. Look at package.json and at the files at the root of the project, then find your row.

What you findWhere HTML is producedWhat to do
vite and react-dom, an index.html with an empty root divIn the browserPrerender the public routes, or move them to a framework
react-scriptsIn the browserThe same, and plan a move: Create React App was deprecated by the React team in February 2025
nextOn the server or at build, by defaultCheck each public route is not marked client-only; see the guide on Next.js
react-router with a react-router.config.ts (framework mode)On the server or at build, depending on the configCheck ssr and prerender in the config
A TanStack Start packageOn the serverCheck the routes you care about are not client-only
astro with React componentsAt build, React only for islandsKeep the text outside client-only islands

The React documentation recommends starting new apps with a framework such as Next.js, React Router or Expo, or a build tool such as Vite. The framework path gives you rendering before the browser. The Vite path gives you an SPA, which is fine for an app behind a login and a problem for pages you want found.

# The quickest test: is the text in the first response?
curl -s https://yourproduct.com/pricing | grep -i '<title>'
curl -s https://yourproduct.com/pricing | grep -c 'a sentence from your pricing page'

If the second command prints 0 while the sentence is plainly on the page in your browser, the page is written by JavaScript after load.

Per-route titles and descriptions in React 19

React 19, stable since December 2024, lets any component render <title>, <meta> and <link> tags. React hoists them into the document head, in the browser and in server rendering. You no longer need a separate library to give each route its own head.

// src/components/Seo.tsx
type SeoProps = {
  title: string;
  description: string;
  path: string;          // "/pricing"
  noindex?: boolean;
};

const ORIGIN = "https://yourproduct.com";

export function Seo({ title, description, path, noindex }: SeoProps) {
  return (
    <>
      <title>{title}</title>
      <meta name="description" content={description} />
      <link rel="canonical" href={ORIGIN + path} />
      {noindex ? <meta name="robots" content="noindex" /> : null}
    </>
  );
}
// src/routes/Pricing.tsx
import { Seo } from "../components/Seo";

export function Pricing() {
  return (
    <>
      <Seo
        title="Pricing | YourProduct"
        description="Two plans, billed monthly, cancel any time. Compare what each includes and start with the one that fits."
        path="/pricing"
      />
      <main>
        <h1>Pricing</h1>
        {/* plan cards */}
      </main>
    </>
  );
}

// src/routes/NotFound.tsx: the catch-all route
export function NotFound() {
  return (
    <>
      <Seo title="Page not found | YourProduct" description="This page does not exist." path="/404" noindex />
      <main><h1>Page not found</h1></main>
    </>
  );
}

Remove the static <title> and description from index.html once every route sets its own, so a page never carries two. On a client-rendered app these tags still only exist after JavaScript runs: React 19 fixes the per-route part, not the rendering part. That is what the next section is for.

On React 18 and older, react-helmet-async does the same job: wrap the app in its provider and render a Helmet component with the tags in each route. It is the library people mean when they search for react helmet. On React 19 you can remove it.

Prerendering the public routes

For a marketing site, the lightest fix is to generate each public route as an HTML file at build. Every crawler then gets the full page, and the app still hydrates in the browser as before.

If you use React Router in framework mode, prerendering is a config option. Keep ssr off to stay a static site and list the routes to generate:

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
  async prerender() {
    return ["/", "/pricing", "/features", "/about", "/blog"];
  },
} satisfies Config;

On a plain Vite app the outline is the same whatever tool you choose:

  1. List the public routes

    The home page, pricing, features, each guide or post. Not the dashboard, not the settings, nothing behind the login.

  2. Render each route to HTML at build

    Either with a prerender plugin for your bundler, or with a small script that renders each route on the server side of React and writes dist/<route>/index.html.

  3. Serve the files, then fall back

    Configure the host to serve dist/pricing/index.html for /pricing, and a 404 for paths that match nothing. The guide on single page applications has the host rules.

  4. Test without JavaScript

    Run the curl commands above on every generated route. The text and the route's own title should now be in the response.

The React-specific traps

These pass every test in your own browser and fail for a crawler. Search your code for each one.

TrapWhy it hurtsFix
Main content fetched in useEffectThe first HTML, even prerendered, has no contentLoad it at build or on the server, pass it as props
onClick with navigate() on cards and menusGoogle follows only <a href> links, so the target page is never foundUse the router's <Link to="/path">, which renders an <a href>
React.lazy around the main textThe text arrives in a later chunk, after a Suspense fallbackLazy-load widgets and charts, never the article or the pricing table
One index.html for every path in the host's rewrite rulesEvery URL answers 200, typos included, which Google may count as soft 404sRewrite known routes only; unknown ones return 404
Tabs and accordions that mount content on openContent not in the DOM until clicked is never readRender all panels, hide the closed ones with CSS
// Before: a crawler sees a div, not a link
<div className="card" onClick={() => navigate("/guides/setup")}>Setup guide</div>

// After: a real link, same look, same client-side navigation
<Link to="/guides/setup" className="card">Setup guide</Link>

What AI crawlers need from a React app

Google renders JavaScript in a second wave; most AI crawlers do not run it at all. A client-rendered React page is blank to the crawlers that feed assistant answers, however well it does in Google.

This matters for a small product. When someone asks an assistant for a tool like yours, the answer is built from pages its crawler could read. A page that exists only after a script runs is not among them.

There is nothing React-specific to add for them. The same fix works: put the text in the first HTML response, keep the links as <a href>, and do not block them in robots.txt. The guide on robots.txt for AI crawlers lists who they are.

A checklist for each public route

  • curl returns the page's main text.
  • Its <title> and description are its own, set by the route component.
  • One canonical, to its own URL.
  • Every link to another page is an <a href> rendered by the router's Link.
  • No main content behind useEffect, React.lazy or a click.
  • Listed in the sitemap.
  • A made-up sibling path returns 404.

When all seven hold for your public routes, React is no longer the reason a page is not ranking. What remains is the page itself: whether it answers the search, and whether anything links to it.

Questions

Check my site, free

Paste your React site's URL into the free check: in about thirty seconds it reads your site, the searches around it and the rivals on them, and shows three findings whole.

  • Free check, no card
  • Read-only, your own accounts
  • Readable by your agent

Read next