Skip to content
ohsudo, Home
Networking

Nginx for a React or Vite single-page app: routes, caching and an API proxy

Serve a React, Vue or Vite single-page app with Nginx: fix 404s on refresh with try_files, cache hashed assets, avoid caching index.html, and proxy /api to your backend.

Beginner3 min readApplies to Nginx 1.18+ · React · Vue · Vite · Ubuntu · Debian · RHEL / Rocky / AlmaLinux

by OhSudo ·

A single-page app is only static files, so Nginx serves it easily, except for one catch. The app handles addresses like /dashboard in the browser, and that file doesn’t exist on the server. So it works until someone refreshes, or opens a link, and then they get a 404.

try_files solves that, and the rest of this guide is about caching correctly. The Nginx Config Generator with these options writes this for your domain.

The config

server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/example.com;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Vite puts a hash in these names, so they can be cached forever.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Unknown paths get the app, which then shows the right page.
location / {
try_files $uri $uri/ /index.html;
}
# index.html must always be fresh, or users keep the old app.
location = /index.html {
add_header Cache-Control "no-cache";
}
}

1. Build and upload

Terminal window
npm ci
npm run build
sudo mkdir -p /var/www/example.com
sudo rsync -a --delete dist/ /var/www/example.com/

Vite and modern React setups write to dist/, while Create React App writes to build/. Upload what’s inside that folder. Nginx doesn’t look in subfolders, so with index.html a level down, you get a 403 Forbidden.

Nginx’s user has to be able to read those files, and the folders above them too. The chmod Calculator can help with the numbers.

Terminal window
sudo chown -R root:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} +
sudo find /var/www/example.com -type f -exec chmod 644 {} +

2. Get HTTPS, and turn it on

Terminal window
sudo certbot certonly --standalone -d example.com \
--pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

If that fails, see Certbot failed to authenticate.

3. Check all three cases

Terminal window
curl -I https://example.com/ # 200, no-cache
curl -I https://example.com/dashboard # 200: that's the fallback working
curl -I https://example.com/assets/index-abc123.js # 200, immutable
curl -I https://example.com/assets/missing.js # 404, not index.html

That last line is why /assets/ has its own try_files. With the fallback, a missing script answers 200 with HTML, and browsers then complain about a wrong MIME type: Expected a JavaScript module script but the server responded with a MIME type of "text/html". You’ll see that right after a deploy too, when someone’s old page asks for a file that’s gone.

Why cache in two different ways?

Files in assets/ have a hash in their name, as in index-abc123.js. A new build means a new name, so they’re safe to keep for a year. index.html keeps its name, and it’s what points to the new files. If a browser keeps an old copy, it runs the old version of your app until the cache runs out.

Watch out for one Nginx rule: add_header in a location cancels all add_header lines from the server block. So if you’ve set security headers at the server level, repeat them in each of these locations, or they silently disappear. Paste curl -I output into the HTTP Headers Analyzer to see what’s really sent.

4. Send /api to your backend

Keeping the API on the same domain avoids CORS altogether. Add this before location /, and your app can call /api/items:

location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

Without a trailing / on proxy_pass, your backend sees the path as it is, /api/items. With http://127.0.0.1:3000/, it sees /items. If it’s down, you’ll get a 502 Bad Gateway.

5. Under a subpath, like /app

The build has to know, or it looks for its files at the root. In vite.config.js, set base: '/app/', and in React Router, set basename="/app". Then:

location /app/ {
alias /var/www/example.com/;
try_files $uri $uri/ /app/index.html;
}

When it goes wrong

You see Means
404 on refresh, or on a shared link No try_files … /index.html
A blank page, with 404s for scripts in the console Wrong base, or the files were uploaded a level too deep
403 Forbidden on / No index.html there, or wrong permissions: see Nginx 403
An old version after a deploy A cached index.html: check its Cache-Control
ERR_TOO_MANY_REDIRECTS See Too many redirects

Warning

Everything in a single-page app is public, including any API key in your code or in a VITE_ variable: they’re all in the JavaScript, for anyone to read. Keep secrets on the backend.

Found this useful? Share it.

Found a mistake? Let us know at contact@ohsudo.com.

Tools

Try it yourself

Articles

Related articles