Please start with a fresh copy of this app: Adopt Me!

Performance is a central concern for front end developers. We should always be striving to serve the leanest web apps that perform faster than humans can think. This is as much a game of psychology as it is a a technological challenge. It's a challenge of loading the correct content first so a user can see a site and begin to make a decision of what they want to do (scroll down, click a button, log in, etc.) and then be prepared for that action before they make that decision.

Enter server-side rendering. This is a technique where you run React on your Node.js server before you serve the request to the user and send down the first rendering of your website already done. This saves precious milliseconds+ on your site because otherwise the user has to download the HTML, then download the JavaScript, then execute the JS to get the app. In this case, they'll just download the HTML and see the first rendered page while React is loading in the background.

While the total time to when the page is actually interactive is comparable, if a bit slower, the time to when the user sees something for the first time should be much faster, hence why this is a popular technique. So let's give it a shot.

First, we need to remove all references to window or anything browser related from a path that could be called in Node. That means whenever we reference window, it'll have to be inside componentDidMount since componentDidMount doesn't get called in Node.

We'll also have change where our app gets rendered. Make a new file called ClientApp.js. Put in there:

import { hydrate } from "react-dom";
import { BrowserRouter, BrowserRouter as Router } from "react-router-dom";
import App from "./App";

hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById("root")
);

This code will only get run in the browser, so any sort of browser related stuff can safely be done here (like analytics.) We're also using ReactDOM.hydrate instead of ReactDOM.render because this will hydrate existing markup with React magic ✨ rather than render it from scratch.

Because ClientApp.js will now be the entry point to the app, not App.js, we'll need to fix that in the script tag in index.html. Change it from App.js to ClientApp.js

Let's go fix App.js now:

// remove react-dom import
// remove BrowserRouter as Router from react-router-dom import

// replace render at bottom
export default App;

We need a few more modules. Run npm install express@4.17.3 to get the framework we need for Node.

Go change your index.html to use ClientApp.js instead of App.js

<script type="module" src="./ClientApp.js"></script>

Now in your package.json, add the following to your "scripts"

"build": "parcel build",
"start": "npm -s run build && node dist/backend/index.js"

And then add this top level item to your package.json:

{
  "targets": {
    "frontend": {
      "source": ["src/index.html"],
      "publicUrl": "/frontend"
    },
    "backend": {
      "source": "server/index.js",
      "optimize": false,
      "context": "node",
      "engines": {
        "node": ">=16"
      }
    }
  }
}

This will allow us to build the app into static (pre-compiled, non-dev) assets and then start our server. This will then let us run Parcel on our Node.js code too so we can use our React code directly in our App as well.

Let's finally go write our Node.js server:

import express from "express";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import fs from "fs";
import App from "../src/App";

const PORT = process.env.PORT || 3000;

const html = fs.readFileSync("dist/frontend/index.html").toString();

const parts = html.split("not rendered");

const app = express();

app.use("/frontend", express.static("dist/frontend"));
app.use((req, res) => {
  const reactMarkup = (
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );

  res.send(`${parts[0]}${renderToString(reactMarkup)}${parts[1]}`);
  res.end();
});

console.log(`listening on http://localhost:${PORT}`);
app.listen(PORT);
  • [Express.js][ex] is a Node.js web server framework. It's the most common one and a simple one to learn.
  • We'll be listening on port 3000 (http://locahost:**3000**) unless a environment variable is passed in saying otherwise. We do this because if you try to deploy this, you'll need to watch for PORT.
  • We'll statically serve what Parcel built.
  • Anything that Parcel doesn't serve, will be given our index.html. This lets the client-side app handle all the routing.
  • We read the compiled HTML doc and split it around our not rendered statement. Then we can slot in our markup in between the divs, right where it should be. Another strategy you can do here is to make an Html.js component that renders the outer shell of your app. This for now suits our needs
  • We use renderToString to take our app and render it to a string we can serve as HTML, sandwiched inside our outer HTML.

This code won't 404 or 500 on bad requests. It's a lot involved to do that with React Router v6. See here to learn more.

Run npm run start and then open http://localhost:3000 to see your server side rendered app. Notice it displays markup almost instantly.

🏁 Click here to see the state of the project up until now: server-side-rendering-1