Fix SPA 404 on Refresh with Nginx try_files

Your React or Vue route 404s on refresh because Nginx looks for a file on disk at that path, finds nothing, and returns a 404 before your client-side router can run. Fix it by serving index.html as a fallback: add try_files $uri $uri/ /index.html; to your location / block, then run nginx -t and reload.

Why direct loads and refreshes 404

Single-page apps use client-side routing. Libraries like React Router and Vue Router (in HTML5 history mode) intercept navigation in the browser and rewrite the URL with the History API, so paths like /dashboard or /users/42 never exist as files on disk. They are virtual, rendered by JavaScript after index.html loads.

When you click an in-app link, this works fine: the router handles the transition and no HTTP request leaves the browser. The trouble starts on a hard load. Refresh /dashboard, paste the URL into a new tab, or follow an external link, and the browser sends a real GET request to Nginx for /dashboard. Nginx tries to find a file or directory at /var/www/app/dashboard, finds nothing, and returns its default 404. Your JavaScript bundle never loads, so the router never gets a chance to render the route.

The fix is to tell Nginx: if the requested path is not a real file, serve index.html instead. That bootstraps your app, and the router reads the URL and renders the correct view. This is identical for React Router with BrowserRouter and Vue Router with createWebHistory(). Hash-mode routing (/#/dashboard) avoids the problem entirely because the server only ever sees /, but most teams want clean URLs.

The try_files fix, and why $uri/ is not optional

Here is the minimal working server block. Put it in /etc/nginx/sites-available/your_domain (edit there, not in the sites-enabled symlink):

server {
    listen 80;
    server_name example.com;
    root /var/www/app/build;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

The directive try_files checks each argument left to right and serves the first one that exists, using the last argument as a fallback. Reading $uri $uri/ /index.html:

  • $uri — try the path as a literal file. This is what serves your real assets: /assets/main.abc123.js, /logo.svg, /favicon.ico.
  • $uri/ — try the path as a directory (so Nginx can serve its index.html).
  • /index.html — the fallback. If the path is not a real file or directory, serve the app shell so the router can take over.

Do not drop $uri down to just try_files /index.html;. That returns index.html for every request, including your JavaScript and CSS, so the browser receives HTML where it expects a script and the app fails to boot. Note that try_files $uri /index.html; (without $uri/) actually works fine for most SPAs, since real files still match $uri first; $uri/ only matters if you rely on directory index resolution. Keeping $uri first is the essential part: real files are served as files, and only virtual routes fall through to the shell. Watch for the classic typo too: it is /index.html with a leading slash and a dot, not index/html.

Make missing static assets return real 404s

The catch-all has a side effect: any path that is not a real file now returns index.html with a 200 OK. A request for /assets/typo.js hands back HTML instead of a 404. Worse, every nonexistent page returns 200, which search engines treat as a soft 404 that wastes crawl budget and muddies indexing.

The fix is to give static assets their own location with a hard =404 fallback so missing files report honestly, while only true app routes fall through to index.html:

server {
    listen 80;
    server_name example.com;
    root /var/www/app/build;
    index index.html;

    # Static build output: real 404 if the file is missing
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback for client-side routes
    location / {
        try_files $uri $uri/ /index.html;
    }
}

The =404 token tells Nginx to return a genuine 404 rather than continuing to a fallback. The expires and immutable Cache-Control header are safe here because hashed filenames change on every build. Match the location prefix to your bundler output: Vite emits /assets/, while Create React App uses /static/. To surface 404s in the UI, also add a wildcard route in your router (a React Router path: "*" route or a Vue Router path: "/:pathMatch(.*)*" NotFound component) so unknown app routes render a real 404 page instead of a blank shell. If you serve the app under a subpath with alias instead of root, the fallback must include that prefix, for example try_files $uri $uri/ /app/index.html;.

Order the /api/ proxy before the catch-all

If your backend lives behind the same Nginx server, location ordering matters for clarity. The location / catch-all is greedy: anything not matched by a more specific prefix lands there and gets index.html. If your API proxy is missing entirely, requests to /api/users return your HTML shell instead of JSON, and your fetch calls fail with confusing parse errors. Define the proxy as its own prefix location so it handles those requests:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
    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;
}

Nginx selects the longest matching prefix location regardless of where it appears in the file, so /api/ reliably beats / even if listed afterward. Still, list it before the catch-all for readability. Place specific locations (/api/, /assets/) first and the SPA fallback last. If you need a custom proxy config, the Nginx config generator can scaffold a correct server block, and the cURL-to-code converter is handy for turning a working curl request against your proxied API into client code.

Validate, reload, and verify

Never reload blindly. Test the configuration syntax first, then reload (reload keeps existing connections alive; restart drops them):

sudo nginx -t
sudo systemctl reload nginx   # or: sudo nginx -s reload

Then verify each request type with curl -I and confirm the status codes match expectations:

RequestExpected
curl -I https://example.com/dashboard200, serves index.html
curl -I https://example.com/assets/main.js200, the real JS file
curl -I https://example.com/assets/missing.js404
curl -s https://example.com/api/healthJSON from your backend

If one URL works but another breaks, do not stack more fallbacks into a single location. Recheck the match order, split static assets from app routes, and use a separate prefix location when different request types need different handlers. If you are debugging proxied JSON, a quick pass through a JSON formatter makes malformed or HTML-instead-of-JSON responses obvious, and a JWT decoder helps confirm auth tokens are surviving the proxy hop. With the three pieces in place — try_files $uri $uri/ /index.html for routes, =404 for static assets, and the proxy location defined — refreshes, direct loads, and deep links all work while genuine 404s stay honest.

Frequently Asked Questions

In-app link clicks are handled entirely by React Router in the browser, so no request reaches the server. A refresh or direct load sends a real GET request to Nginx for a path that has no file on disk, so Nginx returns 404 before your JavaScript can load. Adding try_files $uri $uri/ /index.html serves the app shell as a fallback.

The $uri argument serves real files like your JS and CSS, and $uri/ serves real directories. Keeping these before the /index.html fallback means Nginx only serves the app shell for virtual client-side routes, not for your scripts. Dropping $uri entirely would return index.html for everything and break the app boot.

Give static assets their own location block with a hard fallback, such as location /assets/ { try_files $uri =404; }. The =404 token returns a genuine 404 instead of falling through to index.html, which prevents soft 404s that confuse search engines and crawlers.

The catch-all location / serves index.html for anything not matched by a more specific location, so an API request without its own proxy location gets the HTML shell. Define location /api/ with proxy_pass. Nginx selects the longest matching prefix, so /api/ correctly wins over / regardless of order in the file.