The myth of the single try_files directive persists: many developers believe one configuration line is enough to route all requests to index.html for a Single Page Application. In reality, this naive approach creates subtle production issues. When a SPA is served via Nginx, every request to a non-existent route must be intercepted and forwarded to index.html, where the client-side router (Angular, React, Vue) takes over. But this simple logic breaks down quickly with static assets, API proxies, HTTPS redirects, and especially caching strategies. Without a deep understanding of Nginx behavior, you end up debugging sporadic 404 errors, unserved assets, or worse, blank pages in production that only appear under specific conditions.
The Core Problem: Distinguishing Assets from Routes
The classic pitfall is applying try_files to *everything* indiscriminately. A SPA must serve its static assets (JS, CSS, images) directly from disk, but route requests to non-existent URLs back to index.html. Failing to make this distinction creates two catastrophic scenarios: either you serve index.html when an image is missing (loading the SPA instead of returning a proper 404), or you break the fallback for actual routes entirely. The solution lies in granular Nginx configuration that first tests file existence, then directories, and only then falls back to index.html. Here's a robust setup: location / { try_files $uri $uri/ /index.html; } works for routes but fails for assets with unexpected extensions. A more nuanced approach involves creating specific location blocks for static file types (js, css, png, svg, etc.) with their own caching rules and fallback disabled.
Consider an Angular app deployed in production. A user navigates to /dashboard/settings , a valid route handled by the router. Nginx doesn't find this path on disk, so try_files falls back to index.html, which loads the SPA, and Angular Router displays the correct page. Perfect. But seconds later, a monitoring script attempts to load /api/health , an API endpoint. If your try_files is too permissive, this request also falls back to index.html, returning HTML instead of JSON. The API fails silently, and your monitoring is blind. The solution: use a location structure that explicitly excludes API paths. location ^~ /api/ { proxy_pass http://backend; } before the generic location ensures API requests are never intercepted by fallback logic.
Cache, ETags, and Deployments That Break
A second, often overlooked pitfall concerns asset caching. Files generated by the Angular bundler (main.xyz123.js) include a hash in the filename: excellent for cache-busting. But if you don't configure cache headers correctly, users' browsers continue loading old versions after deployment. Conversely, index.html itself should *never* be cached, because it's the entry point that loads the new SPA. Proper Nginx configuration defines different caching strategies for index.html (no cache) and versioned assets (aggressive cache). Example: location ~* \.js$|css$|png$|jpg$ { expires 1y; add_header Cache-Control "public, immutable"; } for assets, and location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; } for the entry point.
During deployment, the Nginx server generates a new version of index.html (or the same file with updated content). If Cache-Control headers are misconfigured, users' browsers continue serving the cached version, which loads old JavaScript bundles. Users see a partially updated or completely broken application. To prevent this, ensure index.html never has permissive Expires or Cache-Control headers. Service workers complicate matters: if a service worker cached index.html, even Nginx cannot force a refresh. This highlights the importance of configuring the service worker to revalidate index.html on every load.
Error Handling and Intelligent Fallback
A well-configured SPA returns index.html for unknown routes, allowing the Angular router to handle 404s client-side. But Nginx must also handle cases where index.html itself is missing (rare but catastrophic in production). Use error_page 404 /index.html; to ensure that in case of real errors, the browser receives the SPA rather than a generic error page. However, this directive changes the HTTP code returned to the browser: without additional configuration, Angular Router receives a 200 instead of a 404, which can mislead monitoring tools. To preserve correct HTTP semantics, use try_files $uri $uri/ /index.html =404; : the final =404 indicates that if fallback fails, return a real 404.
Complete Production Configuration
Here's a reference Nginx configuration for a production Angular SPA: server { listen 80; server_name example.com; root /var/www/app/dist/browser; # Versioned assets: long-term cache location ~* \.(js|css|png|jpg|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # index.html: no cache location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; } # API proxy location ^~ /api/ { proxy_pass http://backend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # Fallback for SPA routes location / { try_files $uri $uri/ /index.html =404; } } . This configuration clearly separates assets (long-term cache), API requests (direct proxy), and SPA routes (fallback to index.html). It avoids serving index.html for missing assets and preserves HTTP semantics.
Common Pitfalls and Antipatterns
The most dangerous pitfall is enabling fallback for *all* request types, including HEAD and OPTIONS. Some monitoring tools or indexing bots send HEAD requests to static assets. If Nginx responds to a HEAD request for /api/health with index.html, monitoring will think the server is healthy when it may be broken. Another antipattern: using try_files without first testing file existence. try_files $uri =404; followed by implicit fallback via error_page is less clear than explicitly writing try_files $uri $uri/ /index.html =404; . Finally, many developers forget that Nginx reads files from root , not the current directory. If your Angular dist is at /var/www/app/dist/browser but you omitted the root directive, Nginx looks elsewhere, and *everything* falls back.
Conclusion
Serving a SPA without 404s in production requires far more than a single try_files line. You need a clear strategy: distinguish assets from routes, configure caching correctly, proxy APIs, and test real behavior with tools like curl or Postman. Also test deployment scenarios: deploy a new version without clearing browser cache, access non-existent routes, verify versioned assets load properly. Well-designed Nginx configuration is invisible in production: users navigate without friction, deployments are transparent, and HTTP errors are semantically correct. Spending 30 minutes refining this configuration saves hours of debugging at 3 AM.