How to simply prerender your React app

The common (wo)man's SSG for SEO
Roman Lamsal - 11/10/2022

TL;DR: here’s the final repo: https://github.com/romanlamsal/react-prerender-with-vite-example

I recently revamped my online scrumpoker tool Scrumpoker24.com and added a SEO compatible landing page. After all, this is a tool which is supposed to be used, so it needs to be found. I also only wanted to prerender only my landing page and not the other subpages.

I am using React with vite there but every CRA (create-react-app) app will work for the following tutorial. What we will do is add a very simple SSG (static site generation) mechanism which takes a route and outputs an HTML file containing the HTML markup of the body for that route. It will also retain the SPA logic, so your webapp stays dynamic.

Basic Setup

You can skip this part if you already have a functional CRA with some sort of router.

First off we create a fresh React app via CRA. We do not even bother to add typescript or use vite, just to make a point. In real life I would never do this. Though what we do have to add is a router. To get this setup, I run:

# I am already in the directory I will be working in
pnpm create react-app .
pnpm add react-router-dom

We throw everything out except App.js and index.js out of our src directory that CRA bootstraps for us. First, change their extension to .jsx (will need that later for vite). Then we change their contents to the following.


Because this will always be the entry for our clients, i.e. this will be loaded in the browser, we add our react router BrowserRouter here. That’s important for the SSG to work later on.

import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./App"
import { BrowserRouter } from "react-router-dom"

const root = ReactDOM.createRoot(document.getElementById("root"))

            <App />


For simplicity’s sake, we will add two routes + components here and not extract them to separate files.

Also, to showcase later that our generated pages are still reactive, we add a button with a counter.

import { Route, Routes } from "react-router-dom"
import { useState } from "react"

export const App = () => {
    const [counter, setCounter] = useState(0)
    return (
        <div className="App">
                            I am the generated markup! 
                            Clicked: <button onClick={() => setCounter(counter + 1)}>
                <Route path={"/"} element={<div>I am the default route.</div>} />

Now, when start our app with pnpm start we should see “I am the default route” when navigating to http://localhost:3000. When changing the address to http://localhost:3000/generate-me we should see “I am the generated markup! Clicked: 0”. Now we are ready.

Analyzing the build

Once we run pnpm build CRA creates a build directory with only a single HTML file: index.html. The HTML will, typically for SPAs, look something like this:

<!doctype html>
<html lang="en">
    <!-- unimportant things -->
    <script defer="defer" src="/static/js/main.HASH.js"></script>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>

Let’s suppose this was deployed under somewhere.com with an appropriate webserver configured for serving SPAs. Then opening the browser at http://somewhere.com/generate-me will in fact display the expected “I am the generated markup! Clicked: 0”, but a webcrawler will be greated with…

Nothing. Some webcrawlers wait a few millis for content to load before indexing, though. But if they don’t, they will most likely index your website as “You need to enable JavaScript to run this app.” which is not ideal.

Setting up the prerendering

Moving forward, we want to achieve that our build directory also contains a file generate-me.html which

  • contains the markup of the /generate-me route in the body
  • is still dynamic so the button still works.

Hint: If your webserver does not append the .html suffix automatically (e.g. your site is hosted in s3) then you want to generate the file without suffix. I will assume your webserver does.

To do this we have to render the contents of the /generate-me route to a string first. The problem here: we do this in a NodeJS environment, so we do not have access to browser specific APIs like document, window, localStorage and so on.

To solve this we use vite. It is super fast, comes with some browser polyfills (which solve the above problem) and has a nice javascript API.

Update 2022-11-18: stuff like window won’t be polyfilled, sadly. You can polyfill them yourself, see vite config’s ‘define’ on some more intel. You could also add conditionals based on true (which will be true when using the script below).

Scripting time!

First add vite and the vite react plugin via pnpm add vite @vitejs/plugin-react.

Now we need two files: one is our SSG entry entry-server.jsx and one is our vite-powered script gen-static.js.


We create this file in our src directory. It should export a function which renders the DOM of a given path for which we use ReactDOMServer.renderToStaticMarkup (or ReactDomServer.renderToString if your version of react does not support that yet).

Also, we use react router’s StaticRouter instead of the BrowserRouter we used in the index.jsx file (remember: that’s the client entry) because we obviously do not have a browser to derive the current path from.

import ReactDOMServer from "react-dom/server"
import { StaticRouter } from "react-router-dom/server"
import { App } from "./App"

export const render = path => {
    return ReactDOMServer.renderToStaticMarkup(
        <StaticRouter location={path}>
            <App />


Just check the inline comments why we do each thing we do:

const { createServer } = require("vite")
const reactPlugin = require("@vitejs/plugin-react")
const path = require("node:path")
const fs = require("node:fs")

async function genStatic(url) {
    const vite = await createServer({
        // tell vite to properly parse React code
        plugins: [reactPlugin()],
        // prevent vite from starting as a webserver, among other things
        appType: "custom",

    // import our server entry's render method
    // this also adds the polyfills (and other dependencies/optimizations)
    const { render } = await vite.ssrLoadModule("/src/entry-server.jsx")

    // load our default index.html file from the build directory
    const toBuildPath = pathPart => path.join(process.cwd(), "build", pathPart)
    const indexHtmlContent = fs.readFileSync(toBuildPath("index.html")).toString()

    // now we create our DOM markup
    const urlHtmlMarkup = render(url)

    // make sure to add your rendered content as a child of the client entry's render target
    // in the default case that's the div with id="root"
    const urlHtmlContent = indexHtmlContent.replace(
        '<div id="root"></div>', 
        `<div id="root">${urlHtmlMarkup}</div>`

    // finally, write the file to your build directory
    // omitting the suffix can be done, depending on your webserver's configuration
    fs.writeFileSync(toBuildPath(url + ".html"), urlHtmlContent)

    // shutdown vite
    await vite.close()

const urls = ["/generate-me"]


The Final Result

Put it all together by running pnpm build; node gen-static.js in the root of your project. You should then find the file generate-me.html in your build directory with the following content (linted for visibility):

<!doctype html>
<html lang="en">
    <!-- unimportant things -->
    <script defer="defer" src="/static/js/main.HASH.js"></script>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">
        <div class="App">
            <div>I am the generated markup! Clicked: <button>0</button></div>

As you can see, the file does indeed contain the rendered route. Also, the button has a fixed value of 0 - but that’s not really a problem:

We also still load the SPA bundle (aka the client entry) via script tag. This calls ReactDOM.createRoot(document.getElementById("root")).render(element) which effectively means: render element into the div with id=“root”, overwriting everything there currently is. So if you serve your build directory now (pnpx serve build) and go to /generate-me you will see that the button is actually working.

What’s next

This approach is very basic but is extensible. You could automatically find routes based on directory layout, could configure your routes as JSON and then import them as a base to get your URLs to render or add some logic to properly add head tags. Using react-helmet or setting meta tags via javascript in general will not work with this simple approach.

Also, there is a Flash of Content (FOC) if you prerender you index.html and navigate to a route that’s not prerendered. That’s because the browser will first render the static html of your index.html (we are still running an SPA), then load the script bundle which renders the actual content of the route. See for yourself by adding anything to your <div id="root"></div> (on an empty cache). This problem is not big, though, as your browser should cache the bundle and the FOC should happen only once.

Why not use Server Side Rendering (SSR)?

SSR comes either with your manually built webserver doing the SSR (which is not too complicated) or with a framework like NextJS. A smaller version of SSR would be SSG, where Gatsby is good at.

IMHO, The problem with either approach is that it’s way too much boilerplate for rendering some static html. NextJS and Gatsby are really great, but they come with a lot of, perhaps unwanted, side-effects.

Final note

Thank you for reading this article! SSG becomes much less painful when using vite with vue and vite-ssg, which is what I would be normally doing, but it was still fun experimenting with this.