Loading...
Loading...
Marco Franssen

In this post, I want to show you how to make a React single-page application (SPA) with client-side routing work properly with Nginx. I'll also show you how to serve your React app over HTTP/2 and use HTTP/2 server push, as supported at the time of this 2020 tutorial. We'll do this using the Nginx Docker image.
When running your web app with the development server, you generally won't face routing issues. When serving the static build on a production server, however, you may run into them. For example, if you use React Router with a route for /todo/42, your web server will look for a file called /build/todo/42, which doesn't exist. We need to direct the web server to index.html instead.
With the default configuration of the Nginx Docker image, you'll get 404 responses when you request a page handled by the client-side React router directly. We'll explore how to resolve that and add some performance tuning too.
First, we'll create a new React SPA using Create React App. We'll also add the react-router-dom package to reproduce the issue you face when hosting with the default Nginx setup.
# using yarn
yarn create react-app react-router-nginx-http2
# using npm/npx
nxp create-react-app react-router-nginx-http2Let's start our app with the development server and see what it looks like.
# using yarn
yarn start
# using npm
npm run startNow that we have a basic React application, we can add React Router.
# using yarn
yarn add react-router-dom
# using npm
npm install --save react-router-domWe'll also update src/App.js to use the react-router-dom package so we can reproduce the issue when hosting this app with Nginx.
Loading react-router-nginx-http2/src/App.js…
In this example, I've added two links using React Router. Each one shows different heading text.
With the development server still running, navigate to http://localhost:3000/ and try both links. You'll notice that everything works as expected.
Now we can add our Nginx and Docker setup to run the app in an Nginx Docker container. I always prefer to add a docker-compose.yml file because it simplifies running my application.
version: "3.7"
services:
frontend:
image: nginx:alpine
ports:
- "5000:80"
volumes:
- ./build:/usr/share/nginx/html:roIn this example, we mount the static build of our React app in the Nginx web server root. This helps during development because we can serve updated files without relaunching the container.
Now let's run this Docker container to see the issue I described at the beginning of this post.
yarn build
docker-compose upNavigate between the pages in your web browser by clicking the links in your React app. So far, everything works as expected. However, if you refresh the page at http://localhost:5000/about, it fails with a 404 Not Found response. Nginx has no such file to serve: this page only exists client-side in React Router.
We can resolve this by changing the Nginx configuration. To do so, we'll replace the default server configuration in our Nginx container.
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}The setting that makes this work is the try_files line. It tries to serve the requested file or falls back to index.html. Our React code runs from index.html and takes care of navigating between pages client-side.
To use this configuration in our Docker container, we can simply mount it as a volume.
version: "3.7"
services:
frontend:
image: nginx:alpine
ports:
- "5000:80"
volumes:
- ./build:/usr/share/nginx/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:roOf course, you could also build your own Docker image containing the Nginx configuration and your app's assets.
FROM node:13-alpine AS build
COPY package.json yarn.lock ./
RUN yarn
COPY public ./public
COPY src ./src
RUN yarn build
FROM nginx:alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=certs certs /usr/share/nginx/certs
COPY --from=build build /usr/share/nginx/htmlWith this Dockerfile, we use a multi-stage build. The Node 13 image builds our static web app, and the final Nginx image contains the configuration and static assets. A multi-stage Dockerfile limits the image size by leaving the Node.js dependencies and source files out of the production Nginx image.
docker build -t react-router-nginx-http2 .
docker run -p "5000:80" react-router-nginx-http2As you can see, we can now use the Nginx image as an immutable artifact without mounting volumes or changing things at runtime.
The following docker-compose.yml shows how to build and run this image using Docker Compose.
version: "3.7"
services:
frontend:
image: react-router-nginx-http2
build: .
ports:
- "5000:80"docker-compose up --buildThis builds the image, starts the container, and maps port 5000 on your host to port 80 in the container.
HTTP/2 introduced the ability to push assets from the server to the browser. This can reduce page load time because the browser doesn't have to parse the DOM to discover images, stylesheets, and JavaScript before downloading them. With server push, we can send important assets before the browser requests them. For our React app, these would be the JavaScript bundles and stylesheets.
To use HTTP/2 in this browser setup, we need an SSL certificate. Let's generate a self-signed certificate first. You'll need OpenSSL installed. Don't worry if you don't have it; I'll also show you how to do this in the Dockerfile.
mkdir certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout certs/localhost.key -out certs/localhost.crt -config selfsigned-cert.confThis uses selfsigned-cert.conf to generate the certificate unattended, without prompting for details.
Loading react-router-nginx-http2/selfsigned-cert.conf…
In the Dockerfile, I've included this step to generate the certificates automatically when building the Docker image.
Loading react-router-nginx-http2/Dockerfile…
Once we have an SSL certificate, we update the server configuration to serve HTTP/2 over TLS.
server {
listen 443 ssl http2;
ssl_certificate /etc/ssl/certs/localhost.crt;
ssl_certificate_key /etc/ssl/private/localhost.key;
}With all of that in place, we can check whether our web app is served over HTTP/2. The last step is to push the initial dependencies for index.html. We can do that as follows. Check index.html to see which JavaScript files, stylesheets, and images it requests.
location / {
http2_push "manifest.json"
http2_push "favicon.ico"
}Feel free to add more lines yourself. Now it's time to check the changes. Run docker-compose up --build and navigate to https://localhost. Open your browser's developer tools and check the Initiator column in the Network tab to see which assets are delivered through HTTP/2 server push. Compare this with http://localhost:5000, which doesn't use HTTP/2 and therefore can't use server push.
There are many more things you could do to improve performance, such as enabling gzip compression and tuning Nginx worker processes. Plenty of posts already cover these, so I won't repeat them here. They also require a closer look at your application's specifics. You'll need to find the right balance for when to gzip and when not to. One tool that helps you analyze web application performance in the browser is Lighthouse, which I've written about before. I've also included some Nginx tuning articles in the references below.
Try Lighthouse to see whether you can make small performance improvements to the current setup, such as caching your assets.
It's also worth checking your security settings. You can use www.ssllabs.com to check your SSL configuration, including protocols and ciphers. Give the SSL Labs scan a try and adjust your configuration to reach your desired security level. I always aim for at least an A rating, then try to optimize for A+.
You can also use securityheaders.com to check security headers for your web application, including those related to CORS policies. There are many resources explaining how to configure these settings, so I won't go into detail here.
Both topics are outside the scope of this post, but I find them important to mention so you can give them the attention they need.
If you've reached this point, you might want to bookmark the TL;DR for future reference. You can download the full example using the link below.
Here are the most important files we worked on:
We modified the default App.js to use the React Router package.
Loading react-router-nginx-http2/src/App.js…
The Dockerfile creates a self-signed certificate, builds your React application, and hosts it in Nginx.
Loading react-router-nginx-http2/Dockerfile…
The docker-compose.yml file makes it easier to build and run your Docker image from the command line.
Loading react-router-nginx-http2/docker-compose.yml…
This provides the parameters for OpenSSL to generate the self-signed certificate.
Loading react-router-nginx-http2/selfsigned-cert.conf…
This holds the Nginx web server configuration for HTTP/2, including server push for important assets.
Loading react-router-nginx-http2/nginx/conf.d/default.conf…
The following two resources highlight some caveats:
I hope you enjoyed this post. For your production web servers, I can also recommend Let's Encrypt certificates. They won't cost you a penny and provide the TLS certificate needed for this HTTP/2 setup. See my other posts on Let's Encrypt.
import React from "react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
import logo from "./logo.svg";
import "./App.css";
function App() {
return (
<Router>
<div className="App">
<header className="App-header">
<nav>
<ul>
<li>
<Link to="/" className="App-link">
Home
</Link>
</li>
<li>
<Link to="/about" className="App-link">
About
</Link>
</li>
</ul>
</nav>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
</Router>
);
}
function Home() {
return <h2>Home</h2>;
}
function About() {
return <h2>About</h2>;
}
export default App;[req]
default_bits = 2048
default_keyfile = localhost.key
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
C = NL
ST = Limburg
L = Geleen
O = Marco Franssen
OU = Development
CN = localhost
emailAddress = marco.franssen@email.addr
[req_ext]
subjectAltName = @alt_names
[v3_ca]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1FROM alpine as certs
RUN apk update && apk add openssl
COPY selfsigned-cert.conf .
RUN mkdir certs && openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout certs/localhost.key -out certs/localhost.crt -config selfsigned-cert.conf
FROM node:13-alpine AS build
COPY package.json yarn.lock ./
RUN yarn
COPY public ./public
COPY src ./src
RUN yarn build
FROM nginx:alpine
COPY nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
COPY --from=certs certs /usr/share/nginx/certs
COPY --from=build build /usr/share/nginx/htmlimport React from "react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
import logo from "./logo.svg";
import "./App.css";
function App() {
return (
<Router>
<div className="App">
<header className="App-header">
<nav>
<ul>
<li>
<Link to="/" className="App-link">
Home
</Link>
</li>
<li>
<Link to="/about" className="App-link">
About
</Link>
</li>
</ul>
</nav>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
</Router>
);
}
function Home() {
return <h2>Home</h2>;
}
function About() {
return <h2>About</h2>;
}
export default App;FROM alpine as certs
RUN apk update && apk add openssl
COPY selfsigned-cert.conf .
RUN mkdir certs && openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout certs/localhost.key -out certs/localhost.crt -config selfsigned-cert.conf
FROM node:13-alpine AS build
COPY package.json yarn.lock ./
RUN yarn
COPY public ./public
COPY src ./src
RUN yarn build
FROM nginx:alpine
COPY nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
COPY --from=certs certs /usr/share/nginx/certs
COPY --from=build build /usr/share/nginx/htmlversion: '3.7'
services:
frontend:
image: react-router-nginx-demo
build: .
ports:
- "5000:80"
- "443:443"
# Uncomment the volumes to easily test changes in development
# volumes:
# - ./build:/usr/share/nginx/html:ro
# - ./nginx/conf.d:/etc/nginx/conf.d:ro[req]
default_bits = 2048
default_keyfile = localhost.key
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
C = NL
ST = Limburg
L = Geleen
O = Marco Franssen
OU = Development
CN = localhost
emailAddress = marco.franssen@email.addr
[req_ext]
subjectAltName = @alt_names
[v3_ca]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1server {
listen 80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /usr/share/nginx/certs/localhost.crt;
ssl_certificate_key /usr/share/nginx/certs/localhost.key;
server_name localhost;
location / {
root /usr/share/nginx/html;
http2_push /static/css/main.5f361e03.chunk.css;
http2_push /static/js/2.09673864.chunk.js;
http2_push /static/js/main.ee4bd490.chunk.js;
http2_push /static/media/logo.5d5d9eef.svg;
http2_push /manifest.json;
http2_push /favicon.ico;
http2_push /logo192.png;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}Marco Franssen
Use Lego and the ACME DNS-01 challenge to request a Let's Encrypt TLS certificate for local development without exposing your web server to the internet.
Marco Franssen
Build a Go HTTP/2 web server with TLS, self-signed certificates for offline development, and Let's Encrypt certificates managed by the autocert package.
Marco Franssen
Sign Docker image tags with Docker Content Trust, manage signing keys and repository signers, inspect signatures, and enable trust verification.
Marco Franssen
Explore Krypton's phone-based SSH and PGP setup, pairing a laptop with a mobile device to approve server logins and sign Git commits.