# 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.

Updated 2026-09-23 · Source: https://porteur.ai/guides/react-seo

## 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 find | Where HTML is produced | What to do |
| --- | --- | --- |
| vite and react-dom, an index.html with an empty root div | In the browser | Prerender the public routes, or move them to a framework |
| react-scripts | In the browser | The same, and plan a move: Create React App was deprecated by the React team in February 2025 |
| next | On the server or at build, by default | Check 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 config | Check ssr and prerender in the config |
| A TanStack Start package | On the server | Check the routes you care about are not client-only |
| astro with React components | At build, React only for islands | Keep 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.

```bash
# 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.

```tsx
// 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}
    </>
  );
}
```

```tsx
// 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:

```ts
// 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.

> If the build has to fetch data to write a page, fetch it at build. A page generated with an empty list and filled in by useEffect is the same empty page, prerendered.

## The React-specific traps

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

| Trap | Why it hurts | Fix |
| --- | --- | --- |
| Main content fetched in useEffect | The first HTML, even prerendered, has no content | Load it at build or on the server, pass it as props |
| onClick with navigate() on cards and menus | Google follows only <a href> links, so the target page is never found | Use the router's <Link to="/path">, which renders an <a href> |
| React.lazy around the main text | The text arrives in a later chunk, after a Suspense fallback | Lazy-load widgets and charts, never the article or the pricing table |
| One index.html for every path in the host's rewrite rules | Every URL answers 200, typos included, which Google may count as soft 404s | Rewrite known routes only; unknown ones return 404 |
| Tabs and accordions that mount content on open | Content not in the DOM until clicked is never read | Render all panels, hide the closed ones with CSS |

```tsx
// 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

### Is React bad for SEO?

No. React rendered only in the browser is the problem, because the first HTML is empty. React rendered on the server or at build, through a framework or a prerender step, gives crawlers the full page like any static site.

### Do I still need react-helmet?

Not on React 19, which renders <title>, <meta> and <link> from any component and hoists them to the head. On React 18 or older, react-helmet-async manages the head tags per route; remove it once you upgrade, so two systems do not write the same tags.

### Can Google index a React app without server rendering?

Usually, since Google renders JavaScript. But it does so in a later pass that can be deferred, and a failed script or a blocked resource leaves the page empty. Other crawlers, including most AI crawlers, get nothing.

### Should I migrate from Create React App?

Yes, it was deprecated by the React team in February 2025. If the public pages matter for search, move to a framework that renders them before the browser; if the whole thing is an app behind a login, Vite is enough.

### Next.js or React Router for SEO?

Both can render on the server and at build, so both can give crawlers full HTML. Pick the one that fits the rest of your app, then check the output with curl rather than trusting the defaults.

## Read next

- [Single page application SEO: rendering, routes and status codes that Google can read](https://porteur.ai/guides/single-page-application-seo): Are single page applications bad for SEO? The six failure points with a test for each, the fixes ranked by effort, and host rules for real 404s.
- [Next.js SEO: what the framework does for you and what it does not](https://porteur.ai/guides/nextjs-seo): What Next.js gives you for SEO and what still needs your work: metadata, sitemap, robots, rendering, redirects, URLs, images, fonts and links.
- [JavaScript rendering: what Google sees, what the AI crawlers do not](https://porteur.ai/guides/javascript-rendering-and-seo): Google renders JavaScript in a second pass and the AI crawlers do not render it at all. Here is what breaks, how to test it, and what to change.
- [Prerendering](https://porteur.ai/glossary/prerendering): Prerendering means producing a page's HTML before the request. How it differs from SSR and dynamic rendering, how to test it, and where it becomes cloaking.
- [Lovable SEO: why older Lovable sites were not indexed, and what to check now](https://porteur.ai/guides/lovable-seo): Is Lovable good for SEO? It depends on the project's age. How to tell which you have, test what crawlers get, and the checklist still yours to do.
- [robots.txt for AI crawlers: GPTBot, ClaudeBot, PerplexityBot and what to allow](https://porteur.ai/guides/robots-txt-for-ai-crawlers): Decide which AI crawlers to allow in robots.txt, why it matters, and copy‑paste examples for GPTBot, ClaudeBot, PerplexityBot, Google‑Extended and more.
- [JavaScript SEO](https://porteur.ai/glossary/javascript-seo): What JavaScript SEO means, how Googlebot renders, what breaks, how to fix links and content, and how to choose SSR or SSG so pages index fast.

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: https://porteur.ai/
