Create a React app served by Express.js & Node.js (and add TypeScript)

Leejjon
11 min readMar 7, 2021

I found a lot of tutorials explaining how to host React apps online. Some just explain how to copy everything in the build folder to a CDN and stop there, others use complete solutions such as Next.js to be able to do a lot more. I needed more than just a CDN, some logic was required for the routing. However I felt that Next.js was a bit overkill for my use case.

I picked Node.js with Express.js. The way I’ve set up this example makes sure this logic can be written in TypeScript (the same language I use for my React App) and can be put in the same Git repository.

TLDR: You can find the final source on GitHub.

Creating the Node.js runtime

  • Install Node.js and NPM and make sure you can run node -v and npm -v

I used these versions:

  • Create a folder: mkdir frontend-service
  • Browse to a folder: cd frontend-service
  • Initialize a git repository with: git init
  • Create a folder for the Node.js runtime: mkdir node-runtime
  • Initialize the package.json: npm init
  • Install Express.js: npm i express
  • Create an index.js file with the following content:
const express = require('express');
const app = express();

app.use((req, res) => {
res.status(200).send('Hello, world!');
});
// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});
  • Add a start script to the package.json
{
"name": "runtime",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1"
}
}
  • Run the server with npm start and visit http://localhost:8080/ to see if you get a “Hello, world!” response.

Adding the React.js App

With react, we use the create-react-app project generator to easily generate a project with all the TypeScript configuration we need.

  • If you don’t have npx installed, run npm i -g npx
  • Navigate back to the frontend-service folder: cd ..
  • Generate a folder “react-app”:
npx create-react-app react-app --template typescript
  • Browse to this folder: cd react-app
  • Launch the react dev server: npm start
  • Go to http://localhost:3000/ and check if you see the default React.js sample.

When you have created your desired UI, you can build a release with:

npm run build

This generates a build folder with an index.html file and a bunch of .js and .css files which you could potentially just host on any CDN.

But there are a couple of reasons why I host React.js apps on a Node.js runtime instead of a CDN.

  • React Apps are often Single Page Web apps. Rather than having different URL paths pointing to different HTML pages, you want that all of your URL paths resolve to the index.html page. The index.html page will initialize and use the react-router package to display the correct component based on the URL.
  • For Search Engine Optimization (SEO), it is better to render the page title and meta tags on the server. I did find a dynamic way to render this on the client side and even blogged about it. But in my experience the Googlebot sometimes does render the JavaScript correctly, and sometimes it doesn’t.

Updating the Node.js runtime to serve the React App

Now the React App has been generated, we can update our index.js file to serve the static files in the build folder:

'use strict';

const express = require('express');
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, '../react-app/build')));

app.use((req, res) => {
res.status(200).send('Hello, world!');
});

// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});

Rerun npm start and browse to https://localhost:8080/ and see if your React App is being served.

Adding client side routing to the React App

To use client side routing, you have to install the following packages:

npm i react-router-dom @types/react-router-dom

If you haven’t already, this is a good time to open the frontent-service in an IDE like WebStorm or Visual Studio Code.

import React from 'react';
import './App.css';
import {BrowserRouter, Route, Link} from "react-router-dom";

const about: React.FC = () => {
return (
<div>About</div>
);
};

const home: React.FC = () => {
return (
<div>Home</div>
);
};

const news: React.FC = () => {
return (
<div>News</div>
);
};


const App: React.FC = () => {
return (
<BrowserRouter>
<div>
<Link to="/">Home</Link> <Link to="/about">About</Link> <Link to="/news">News</Link>
<br />
<Route exact path="/" component={home}/>
<Route exact path="/about" component={about}/>
<Route exact path="/news" component={news}/>
</div>
</BrowserRouter>

);
}

export default App;

Run npm start in the react-app folder to see this client side routing in action on the dev server by clicking on the links on the page:

Click on the links to switch pages.

Routing any URL to the React.js App

Again: if you were to do a npm run build and put everything in the build folder on a CDN, this routing would not work. It would not work either with the Node.js service we created so far.

You can try it out by running npm start in the node-runtime folder. Navigating through the links goes well until you refresh the page, then it will resolve Hello World! again.

Our Node.js service needs to point all of the following URLs to the index.html:

To make this happen, replace the index.js file in your frontend-service folder with the following code:

'use strict';

const express = require('express');
const path = require('path');
const app = express();


// This code makes sure that any request that does not matches a static file
// in the build folder, will just serve index.html. Client side routing is
// going to make sure that the correct content will be loaded.
app.use((req, res, next) => {
if (/(.ico|.js|.css|.jpg|.png|.map)$/i.test(req.path)) {
next();
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(__dirname, 'build', 'index.html'));
}
});
app.use(express.static(path.join(__dirname, 'build')));// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});

This code in bold makes everything work. Run npm start to verify that it works. After clicking on “About”, refresh the page.

The caching strategy here is to allow caching for requests on all static files, except for those URLs that result in index.html. This is to prevent that users will load an old index.html that points to old main.<HASH>.chunk.js files that no longer exist. That scenario would result in crashing your React.js app before it is even initialized.

You can see in the FireFox developer tools that only the .css, .js and pictures are being cached:

Adding TypeScript to the Node.js runtime

Installing the dependencies:

npm i -D @types/express @types/node typescript ts-loader ts-node webpack webpack-cli webpack-node-externals copy-webpack-plugin

In the project, create a tsconfig.json file:

{
"compilerOptions": {
/* Basic Options */
"target": "es2017",
"module": "commonjs",
"lib": ["es2017"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
},
"include": ["index.ts"]
}

This include: ["index.ts"] is optional, but with stuff like webpack I want it to only do something with files that I specify.

Next, add a webpack.config.ts file:

const path2 = require("path");
const nodeExternals = require("webpack-node-externals");
const copyFiles = require('copy-webpack-plugin');

module.exports = {
entry: './index.ts',
target: "node",
mode: "production",
externals: [nodeExternals()],
resolve: {
extensions: ['.ts']
},
module: {
rules: [
// all files with a `.ts` extension will be handled by `ts-loader`
{ test: /\.ts$/, loader: 'ts-loader', exclude: /node_modules/}
]
},
plugins: [new copyFiles({ patterns: [{ from: '../react-app/build', to: 'build'}]})],
output: {
path: path2.resolve(__dirname, 'dist'),
filename: 'index.js'
},
};

The “path2” name is a bit silly, but because we already import “path” in the new index.ts that we are going to create next.

Rename the index.js to index.ts and modify it to the following:

'use strict';

import {NextFunction, Request, Response} from "express";

const express = require('express');
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, 'build')));

// This code makes sure that any request that does not matches a static file
// in the build folder, will just serve index.html. Client side routing is
// going to make sure that the correct content will be loaded.
app.use((req: Request, res: Response, next: NextFunction) => {
if (/(.ico|.js|.css|.jpg|.png|.map)$/i.test(req.path)) {
next();
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(__dirname, 'build', 'index.html'));
}
});

// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});

In the package.json, make sure npm start points to the compiled dist/index.js:

"scripts": {
"start": "node dist/index.js",
"build": "webpack",
"test": "echo \"Error: no test specified\" && exit 1"
},

Now you can build with npm run build and run npm start again for the app to work!

Automatically update your running Node.js server

You can add the HotModuleReplacementPlugin to update your Node.js runtime on the fly without having to restart your server. For this you have to create a second webpack configuration ‘webpackdev.config.ts:

const webpack = require("webpack");
const pathDev = require("path");
const nodeExternalsDev = require("webpack-node-externals");
const copyFilesDev = require('copy-webpack-plugin');

module.exports = {
entry: ["webpack/hot/poll?100", './index.ts'],
target: "node",
mode: "development",
externals: [
nodeExternalsDev({
allowlist: ["webpack/hot/poll?100"]
}
)
],
resolve: {
extensions: ['.ts', '.js']
},
module: {
rules: [
// all files with a `.ts` extension will be handled by `ts-loader`
{ test: /.tsx?$/, loader: 'ts-loader', exclude: /node_modules/}
]
},
plugins: [new webpack.HotModuleReplacementPlugin(), new copyFilesDev({ patterns: [{ from: '../react-app/build', to: 'dist/build'}]})],
output: {
path: pathDev.resolve(__dirname, 'dist'),
filename: 'index.js'
},
};

Add the following code to your index.ts:

/**
* Webpack HMR Activation
*/

type ModuleId = string | number;

interface WebpackHotModule {
hot?: {
data: any;
accept(
dependencies: string[],
callback?: (updatedDependencies: ModuleId[]) => void,
): void;
accept(dependency: string, callback?: () => void): void;
accept(errHandler?: (err: Error) => void): void;
dispose(callback: (data: any) => void): void;
};
}

declare const module: WebpackHotModule;

if (module.hot) {
module.hot.accept();
module.hot.dispose(() => server.close());
}

And finally open up two terminals:

  • One with npm run webpack
  • One with npm start

Then you’ll see your code changes be updated in the running node server when you save a file that you modifed:

Adding correct titles and meta descriptions

The React App now has one default title, which is “React App” defined in the react-app/public/index.html file. Since we added the react-router-dom, one would expect every “route” to have it’s own title.

To add a title to every route, we can use the react-helmet module:

npm i react-helmet @types/react-helmet

Add React-Helmet to your App.tsx:

import React from 'react';
import './App.css';
import {BrowserRouter, Route, Link} from "react-router-dom";
import {Helmet} from "react-helmet";

const about: React.FC = () => {
return (
<div>
<Helmet>
<title>About page</title>
<meta name="description" content="This page explains everything about our react app."/>
</Helmet>
<div>About</div>

</div>
);
};

const home: React.FC = () => {
return (
<div>
<Helmet>
<title>Home page</title>
<meta name="description" content="This page is the home page."/>
</Helmet>
<div>Home</div>

</div>
);
};

const news: React.FC = () => {
return (
<div>
<Helmet>
<title>News page</title>
<meta name="description" content="This page shows the latest news."/>
</Helmet>
<div>News</div>

</div>
);
};

const App: React.FC = () => {
return (
<BrowserRouter>
<div>
<Link to="/">Home</Link> <Link to="/about">About</Link> <Link to="/news">News</Link>
<br/>
<Route exact path="/" component={home}/>
<Route exact path="/about" component={about}/>
<Route exact path="/news" component={news}/>
</div>
</BrowserRouter>
);
}

export default App;

When you run the app again and browse through the navigation links, you should see the page content and page title and meta description change along:

However, this is not enough. Remember what I said earlier about the Googlebot. If you keep up with the latest React.js and JavaScript libraries, chances are it uses an older version of Chrome and fail to render your JavaScript (for whatever reason) and then displays ugly search results like below:

This can hurt your SEO a lot.

This was after I managed to add some server side rendering for the meta description:

The title here is still default, but you can make it dynamic too.

Adding dynamic titles and meta description on the Node.js runtime

The index.html is generated by running npm run build in the react-app project, so we have to put placeholders in there which we can fill in on the Node.js runtime.

Modify the file in react-app/public/index.html to:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>$OG_TITLE</title>
<meta name="description" content="$OG_DESCRIPTION" />
<meta property="og:title" content="$OG_TITLE" />
<meta property="og:description" content="$OG_DESCRIPTION" />

</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Then in the node-runtime/index.ts file:

'use strict';

import {NextFunction, Request, Response} from "express";
import ErrnoException = NodeJS.ErrnoException;

const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();

interface PageTitleAndDescription {
title: string;
description: string;
}

interface routeToPageAndDescriptionMapping {
[path: string]: PageTitleAndDescription;
}


const pageTitlesAndDescriptions: routeToPageAndDescriptionMapping = {
'/': {title: 'Home page', description: 'This is the home page.'},
'/news': {title: 'News page', description: 'This is the news page.'},
'/about': {title: 'About page', description: 'This is the about page.'}
};


// This code makes sure that any request that does not matches a static file
// in the build folder, will just serve index.html. Client side routing is
// going to make sure that the correct content will be loaded.
app.use((req: Request, res: Response, next: NextFunction) => {
if (/(.ico|.js|.css|.jpg|.png|.map)$/i.test(req.path)) {
next();
} else {
const filePath = path.resolve(__dirname, 'build', 'index.html')
fs.readFile(filePath, 'utf8', (err: ErrnoException | null, data: string) => {
let title = pageTitlesAndDescriptions[req.path]?.title || 'Page not found';
let description = pageTitlesAndDescriptions[req.path]?.description || 'This is the page not found page.';

data = data.replace(/\$OG_TITLE/g, title);
data = data.replace(/\$OG_DESCRIPTION/g, description);

res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.send(data);
});

}
});

app.use(express.static(path.join(__dirname, 'build')));


// Start the server
const PORT = process.env.PORT || 8080;
const server = app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});

/**
* Webpack HMR Activation
*/

type ModuleId = string | number;

interface WebpackHotModule {
hot?: {
data: any;
accept(
dependencies: string[],
callback?: (updatedDependencies: ModuleId[]) => void,
): void;
accept(dependency: string, callback?: () => void): void;
accept(errHandler?: (err: Error) => void): void;
dispose(callback: (data: any) => void): void;
};
}

declare const module: WebpackHotModule;

if (module.hot) {
module.hot.accept();
module.hot.dispose(() => server.close());
}

Go test it out by browsing to the Home, News and About pages.

That’s it

I hope this helps with hosting React.js Apps. Feel free to ask any questions in the comments below. You can find the final source on GitHub.

--

--

Leejjon

Java/TypeScript Developer. Interested in web/mobile/backend/database/cloud. Freelancing, only interested in job offers from employers directly. No middle men.