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.
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
npm cinpm run buildsudo mkdir -p /var/www/example.comsudo 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.
sudo chown -R root:www-data /var/www/example.comsudo 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
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 -tsudo systemctl reload nginxIf that fails, see Certbot failed to authenticate.
3. Check all three cases
curl -I https://example.com/ # 200, no-cachecurl -I https://example.com/dashboard # 200: that's the fallback workingcurl -I https://example.com/assets/index-abc123.js # 200, immutablecurl -I https://example.com/assets/missing.js # 404, not index.htmlThat 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.