Home/AI-built websites/React and Vite pre-rendering

Make a React and Vite Site
Readable to Google and ChatGPT

If View Page Source shows an empty <div id="root">, your React app builds its content in the browser, and crawlers that don't run JavaScript see nothing. Here's a tested way to put the content into the HTML, with the code.

TestedReactVitePre-rendering
0
words of body text in the HTML before
74
words on the homepage after (29 and 34 on the other two pages)
1
title per page, after removing the default
0
console errors in the browser after

From our test on a three-page site, 15 to 16 October 2026.

What we tested

We built a small three-page café site (home, menu, contact) the way AI builders build them: React, Vite and React Router, with the content drawn in the browser. We confirmed it served an empty page to a crawler, applied the steps below, and checked the result.

  • Versions: Node 24, React 19.2, Vite 8.3, React Router 6.30 and vite-react-ssg 0.9.2.
  • Result: the raw HTML went from 0 words of body text to 74, 29 and 34 words on the three pages, the same text a browser shows. Each page got its own HTML file.

What we haven't tested. Lovable or Replit exports, how those platforms treat changes made outside their editor, React Router 7, other pre-rendering tools, deployment to a particular host, and whether AI crawlers then read the pages. This guide is for a React app in a repository you can edit.

Before you start

  • Confirm the problem with the 10-second check: an empty <div id="root"> and none of your text in the page source.
  • You need the project as code you can edit and run, with a build command that works.
  • Check your React Router version. The tool we used supports version 6, and we didn't test version 7.

The steps

1. Install the pre-rendering tool

npm install react-router-dom@6 vite-react-ssg

2. Move your routes into a routes file

If your app has <Routes> and <Route> elements inside a <BrowserRouter>, turn them into a list. The entry on each route tells the tool which file the page lives in.

// src/routes.tsx
import type { RouteRecord } from "vite-react-ssg";
import App from "./App";
import Home from "./pages/Home";
import Menu from "./pages/Menu";

export const routes: RouteRecord[] = [
  {
    path: "/",
    element: <App />,
    children: [
      { index: true, element: <Home />, entry: "src/pages/Home.tsx" },
      { path: "menu", element: <Menu />, entry: "src/pages/Menu.tsx" },
    ],
  },
];

3. Replace the entry point

// src/main.tsx
import { ViteReactSSG } from "vite-react-ssg";
import { routes } from "./routes";
import "./index.css";

export const createRoot = ViteReactSSG({ routes });

4. Use an Outlet in your layout

Your top-level component now renders the current page through Outlet, instead of its own <Routes>.

// src/App.tsx
import { Link, Outlet } from "react-router-dom";

export default function App() {
  return (
    <>
      <header>
        <nav><Link to="/">Home</Link> <Link to="/menu">Menu</Link></nav>
      </header>
      <main>
        <Outlet />
      </main>
    </>
  );
}

5. Change the build command

// package.json
"build": "tsc -b && vite-react-ssg build"

Keep your existing type-check if you have one. The pre-render step replaces vite build.

6. Give each page its own head

// src/pages/Menu.tsx
import { Head } from "vite-react-ssg";

export default function Menu() {
  return (
    <>
      <Head>
        <title>Menu | Harbour Street Coffee, Falmouth</title>
        <meta name="description" content="Our coffee and pastry menu with prices." />
        <link rel="canonical" href="https://yoursite.com/menu" />
      </Head>
      <h1>Menu</h1>
      {/* the rest of the page */}
    </>
  );
}

Business details can go in the same head as structured data. In our test this was read back correctly from the raw HTML:

<Head>
  <script type="application/ld+json">
    {JSON.stringify({
      "@context": "https://schema.org",
      "@type": "CafeOrCoffeeShop",
      "name": "Harbour Street Coffee",
      "address": { "@type": "PostalAddress", "streetAddress": "12 Harbour Street",
        "addressLocality": "Falmouth", "postalCode": "TR11 3AA", "addressCountry": "GB" }
    })}
  </script>
</Head>

7. Delete the default title from index.html

This is the one people miss. If index.html still has its own <title>, every page ends up with two: the page's own title, then the default. Browsers use the first, but a crawler could use either. Delete the default title line from index.html.

8. Add robots.txt, sitemap.xml and 404.html

Put them in the public folder. Our launch checklist shows what goes in them.

Check it worked

npm run build
npx serve dist

Open the address it prints, then:

  • View Page Source on each page. Your text should be there, with one title and one description.
  • Run curl -s http://localhost:3000/menu | grep -i "a word from the menu". It should print a match.
  • Run curl -I http://localhost:3000/no-such-page. It should say 404.
  • Click a link in the browser. The page should change with no full reload, and the console should show no errors.

Four things that go wrong

All four came up in our test.

  • Two titles per page. See step 7.
  • “window is not defined”. Pre-rendering runs your components on the server, where there's no window, document or localStorage. Code that touches the browser while rendering made our build fail with ReferenceError: window is not defined. Move it into a useEffect, which runs only in the browser.
  • Content that loads after the page runs isn't in the HTML. We put a list into state inside useEffect. It showed in the browser but was absent from the built HTML. Anything crawlers need to read has to be in the page when it's built, not fetched later.
  • Unknown URLs return 200. Pre-rendering doesn't fix this. Add a 404.html and check how your host treats unknown paths. In our test it returned a 404 on a static file server.

Will AI crawlers read it now?

The content is in the HTML, which is what a crawler that doesn't run JavaScript needs. We haven't run an AI-answer test on the result, and being readable doesn't guarantee a recommendation. It removes one reason a site would be missing. The background is in Is your AI-built website invisible?

Frequently Asked Questions

Will this work on my Lovable or Replit site?

Lovable's current projects are built on TanStack Start, which already sends its content in the HTML, so they don't need it. The Replit project we tested uses the wouter router, not React Router, so this guide's tool doesn't apply to it. There is a separate tested guide for Replit at /ai-built-websites/replit. If your site is a React app built with Vite and React Router, and you can edit the code in a repository, the steps should apply. We tested on a site we built ourselves, and we haven't tested how Lovable or Replit treat changes made outside their editor.

Do I have to rewrite my site?

No. In our test the content of the pages didn't change. We moved the routes into a routes file, changed the entry point and the build command, and added a head to each page. Code that uses the browser while rendering needs a small change (see the gotchas below).

Does pre-rendering mean the site stops being interactive?

No. The server-rendered HTML is the first thing a crawler or visitor gets, then the app loads and takes over. In our test, clicking a link still navigated client-side and no errors appeared in the browser console.

Is there another way?

Yes. You can move the site to a framework that renders on the server by default, such as Next.js or Astro. We haven't tested a migration for this guide.

Sources

Check the
technical side

Chad's free scan checks how crawlers read your site, and gives every technical issue with the fix.

Run the free scan →