A single-page app routes in the browser, so a refresh on /dashboard makes Nginx look for a file that doesn’t exist. The fix is one try_files line that falls back to index.html, letting the app’s router take over.
server {
listen 80;
server_name app.example.com;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Hashed build assets: cache hard. They have a content hash in the name,
# so a new deploy ships new filenames and never serves stale code.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Never cache the HTML entry point, or users get a stale app shell.
location = /index.html {
add_header Cache-Control "no-cache";
}
}
The important parts:
try_files $uri $uri/ /index.html;— serve the real file if it exists, otherwise hand/index.htmlto the browser so the SPA router resolves the route. No redirect, status stays 200.- Serve real assets directly. The
/assets/block (adjust to your bundler — Vite uses/assets/, CRA uses/static/) returns=404for genuinely missing files instead of falling back to HTML, so a broken script tag fails loudly instead of returning HTML with a JS content-type. - Cache the hashed assets
immutablefor a year, but keepindex.htmluncacheable — that’s how a deploy goes live instantly: the HTML (always fresh) points at the new hashed filenames.
If your API is on the same domain, put it in its own
location /api/ { proxy_pass … }above the SPA fallback so API calls don’t get the HTML shell.
sudo nginx -t && sudo nginx -s reload