Vite + React deployed with Nginx: “Failed to load module script… MIME type text/html” but assets return application/javascript via curl

Summary

A Vite + React single-page application deployed on Nginx throws Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html" in the browser. This occurs despite curl showing the correct Content-Type: application/javascript for the file. The root cause is typically a mismatch between the requested URL and the actual file on disk, triggering Nginx’s try_files directive to fall back to index.html for a module chunk that is supposed to return a 404 or valid JS. In this specific Vite context, it is often caused by subresource integrity or hash mismatches, or attempting to load a relative path that resolves incorrectly when the app is served from a subdirectory.

Root Cause

The discrepancy between curl and the browser console usually stems from one of two critical issues:

  • The Hash Mismatch (Asset Integrity): Vite generates bundle filenames with content hashes (e.g., index-Bd9j2k.js). If the browser receives a slightly different HTML file (e.g., due to a deployment artifact, a proxy rewriting content, or an old index.html being cached) than what the Vite build generated, the browser requests a JS file that does not exist on the server.
  • The Nginx Fallback Trap: The try_files $uri $uri/ /index.html; directive is a double-edged sword. If a specific JS module file (e.g., /assets/index-Bd9j2k.js) is missing, Nginx falls back to /index.html instead of returning a 404. Since index.html is served with Content-Type: text/html, the browser throws the MIME type error because it expected JavaScript but received HTML.

Why This Happens in Real Systems

In production environments, this issue usually arises from a breakdown in the deployment pipeline or server configuration:

  • CDN or Proxy Interference: A CDN or reverse proxy might be stripping or modifying headers, or aggressively caching index.html. If the HTML is cached but the JS bundle is updated, the hash reference becomes stale.
  • Inconsistent Build/Deploy: If the build artifact is not atomically swapped (e.g., you build into the directory while Nginx is serving it), the browser might fetch the new index.html referencing new hashes, but request old (now deleted) JS chunks. Nginx then returns the index.html (as the fallback) for the missing JS file.
  • Base Path Misconfiguration: If the Vite base config does not match the Nginx root or location setup, the browser requests files relative to the wrong path. Nginx fails to find the file and serves index.html instead.

Real-World Impact

  • Application Failure: The React application does not load at all. The JavaScript entry point is invalid, leaving the user with a blank screen.
  • Broken User Experience: The site is effectively down for all users experiencing the cached or specific route state.
  • Debugging Difficulty: The error message is generic, and curl verifying the headers correctly makes engineers suspect server misconfiguration (Mime types) rather than file system absence or fallback behavior.
  • SEO Penalty: Search engine crawlers will fail to parse the page content as the JS bundle is invalid.

Example or Code

Below is the specific Nginx configuration block that exposes the vulnerability.

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    root /var/www/todo;
    index index.html;

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

The problem lies in the try_files line. If the browser requests /assets/module-Bd9j2k.js and that file is missing (due to hash mismatch or wrong path):

  1. Nginx checks $uri (the file) -> Not Found.
  2. Nginx checks $uri/ (directory) -> Not Found.
  3. Nginx serves /index.html.

The browser receives index.html (text/html) for a request expecting JS.

How Senior Engineers Fix It

Senior engineers focus on the “fallback” logic and asset integrity:

  1. Enforce 404 for missing assets: Do not allow the SPA fallback to catch missing static assets. Specific static asset locations should return 404s.
    • Fix: If serving a standard Vite build, ensure the try_files is strictly scoped or use an if statement to prevent HTML fallback for known asset extensions.
  2. Cache Busting: Ensure the web server and CDN do not cache index.html aggressively.
    • Fix: Set Cache-Control: no-cache for index.html.
  3. Hard Reload Deployment: If the issue is a hash mismatch, the fix is a clean deployment (remove old build artifacts completely before deploying new ones) to ensure the HTML and JS hashes match perfectly.
  4. Check base Config: Verify that the base property in vite.config.js matches the URL path where the app is served.

Why Juniors Miss It

  • Header Obsession: Juniors often see the MIME error and immediately assume the Nginx mime.types file is broken. They waste time verifying application/javascript is listed, missing that the content being served is actually the fallback HTML.
  • Over-reliance on curl: When curl -I shows a 200 OK and correct headers for the specific file path, they assume the file exists. They fail to realize that curl might be hitting a different cache state or that the Nginx fallback returns a 200 OK status code for index.html (which is correct for a SPA route but wrong for a missing JS file).
  • Blind Trust in Framework Defaults: They assume try_files $uri $uri/ /index.html; is a “magic” solution for SPAs without understanding that it applies to all requests, including static assets.