A plain Vite React build ships an HTML file whose body is one empty <div>. Everything a reader sees arrives after JavaScript downloads, parses and runs. For an application behind a login that is completely fine. For anything you want indexed, shared as a link, or readable when a script fails, it is a problem you inherit without choosing it.
The usual answer is to adopt Next.js or Remix or Astro. Sometimes that's right. But if what you have is a mostly-static site with a bit of interactivity, you can get real HTML out of a Vite build in about forty lines and keep the rest of your setup exactly as it is. This site does that — every page you can reach here was rendered to HTML at build time — and this is how, including the parts that bite.
Two pieces: multiple entry points, then render each one
Vite builds a single-page app by default, but it has multi-page support built in. You hand build.rollupOptions.input a map of HTML files, and each one gets its own entry, its own bundle and its own real file on disk at the right path.
That alone gets you separate URLs that don't depend on a client router or a rewrite rule — /articles/some-post/index.html genuinely exists. It does not yet get you content in them. For that you need a build step that imports each page's component, renders it to a string, and writes that string into the HTML.
The part that makes this pleasant is that Vite will load your application modules for you. Start a throwaway server in middleware mode and call ssrLoadModule on each page's entry — you get the real module graph, with TypeScript, JSX, path aliases and CSS imports all handled the same way they are in dev. No second build configuration, no separate SSR bundle.
Then it's renderToString from react-dom/server, and a string replace of the empty root div with a full one. Run it as a plugin with apply: 'build' and enforce: 'post' so it happens after the normal bundle is written — and so the plugin can't recurse into the throwaway server it just started.
The thing to be clear-eyed about: this is not hydration
This is where a lot of write-ups get vague, so I'll be blunt about the trade.
Proper server rendering pairs renderToString with hydrateRoot on the client: React adopts the existing DOM, attaches event handlers, and reuses the markup. What I've described uses createRoot, which throws the prerendered markup away and rebuilds it.
So the prerendered HTML is doing exactly two jobs: it is what crawlers and link unfurlers read, and it is what a human sees before the bundle finishes. Then React replaces it with an identical tree.
That sounds wasteful, and it is, but it buys something real: you never have to care about hydration mismatches. No warnings about server and client markup differing, no defensive useEffect dances to avoid rendering a date or a random value during SSR, no double-render bugs. For a content site the cost is one extra render of a tree you were going to render anyway, and the benefit is that an entire category of confusing bug never appears.
If your page is heavy and interactive, take hydration and accept the mismatch discipline. If it's mostly content, this trade is a good one, and you should make it knowingly rather than discover it later.
Your components now run in Node, briefly
At build time there is no window, no document, no localStorage, no matchMedia. Any component that touches those during render will throw.
In practice this is less painful than it sounds, because the rule is the same one that already makes components testable: browser access belongs in an effect, not in the render body. Effects don't run during renderToString, so anything inside one is automatically safe. What catches people is module-level code — a const isMobile = window.innerWidth < 768 at the top of a file runs on import, before any of your component logic gets a chance to be careful.
Never let the prerender break the deploy
This is the design decision I'd argue hardest for, because it's the one that decides whether you trust the setup at 2am.
Wrap each page's prerender in its own try/catch and, on failure, warn and move on. The bundle is already written at that point. A page that fails to prerender still ships as a working client-rendered page — it just goes out without the crawler-visible copy, and the build log says so loudly.
The alternative is a deploy that fails outright because one component reached for document. Given the choice between "the whole site can't ship" and "one page ships slightly degraded, with a warning", the second is obviously right, and it has to be decided upfront because in the moment the pressure is to ship something.
Derive the page list from your content
One structural point that matters more than any of the code. Don't hand-maintain the list of pages.
Here, one array of article metadata drives everything: rollupOptions.input, the prerender loop, the index listings, the related links, the RSS feeds and the sitemap. Adding an article is a row in that file. The sitemap cannot drift out of sync with reality, because there is no second list to forget — I proved that to myself by renaming a slug and watching every reference follow.
If you take one thing from this, take that rather than the prerender code. A build that derives its outputs from a single source is the difference between a setup that stays correct and one that quietly rots.
What it's worth
View source on this page and the article text is there, in the HTML, before any script runs. That means a crawler that doesn't execute JavaScript still gets the content, a shared link unfurls with a real description, and someone on a bad connection sees words rather than a blank rectangle while the bundle arrives.
None of which required adopting a framework, changing the component model, or learning a new routing convention. For a site that is mostly content with some interactivity, that is a good trade — and knowing exactly where the seams are is the point of doing it yourself.