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 oldindex.htmlbeing 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.htmlinstead of returning a 404. Sinceindex.htmlis served withContent-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.htmlreferencing new hashes, but request old (now deleted) JS chunks. Nginx then returns theindex.html(as the fallback) for the missing JS file. - Base Path Misconfiguration: If the Vite
baseconfig does not match the Nginxrootorlocationsetup, the browser requests files relative to the wrong path. Nginx fails to find the file and servesindex.htmlinstead.
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
curlverifying 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):
- Nginx checks
$uri(the file) -> Not Found. - Nginx checks
$uri/(directory) -> Not Found. - 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:
- Enforce
404for 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_filesis strictly scoped or use anifstatement to prevent HTML fallback for known asset extensions.
- Fix: If serving a standard Vite build, ensure the
- Cache Busting: Ensure the web server and CDN do not cache
index.htmlaggressively.- Fix: Set
Cache-Control: no-cacheforindex.html.
- Fix: Set
- 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.
- Check
baseConfig: Verify that thebaseproperty invite.config.jsmatches 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.typesfile is broken. They waste time verifyingapplication/javascriptis listed, missing that the content being served is actually the fallback HTML. - Over-reliance on
curl: Whencurl -Ishows a 200 OK and correct headers for the specific file path, they assume the file exists. They fail to realize thatcurlmight be hitting a different cache state or that the Nginx fallback returns a 200 OK status code forindex.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.