Fix Django Static Files Served with the Wrong MIME Type
Fix Refused to apply style and Expected a JavaScript module script errors for Django static files: HTML 404 pages served as CSS, missing mime.types in Nginx, .mjs and .wasm types, and WhiteNoise.
Problem statement
Pages render without styles or scripts, and the browser console shows errors like:
Refused to apply style from 'https://example.com/static/css/site.css' because its MIME type
('text/html') is not a supported stylesheet MIME type, and strict MIME checking is enabled.
Failed to load module script: Expected a JavaScript module script but the server responded
with a MIME type of "application/octet-stream".
Browsers check the Content-Type of stylesheets and scripts, strictly so when the site sends X-Content-Type-Options: nosniff (which Django's SecurityMiddleware does by default). A wrong type has two very different causes: the server is sending the wrong file entirely (typically an HTML error page instead of the CSS), or it is sending the right file with the wrong type label. This guide tells them apart and fixes both.
- Request the file directly with
curl -sIand read the status code andContent-Type. 404or200withtext/html: the file is not where the URL points, and an error page or SPA fallback was returned. Fixcollectstatic,STATIC_ROOT, and the Nginxalias(see the static files guide).200withapplication/octet-streamortext/plain: Nginx is missinginclude mime.types;or the extension is not in the table (often.mjs,.wasm,.webmanifest).- Add missing types in Nginx with an extra
typesblock at thehttplevel, or viaWHITENOISE_MIMETYPESwhen WhiteNoise serves the files. - Keep
X-Content-Type-Options: nosniff; fix the types instead.
Step-by-step solution
1. Look at the actual response
curl -sI https://example.com/static/css/site.4f2a91c3e7b0.css | grep -iE "^(HTTP|content-type|content-length|server)"
Use the exact URL from the page source (hashed name included). Then decide which case you are in.
2. Case A: the response is HTML (the file is missing)
A 404 with text/html, or a 200 with text/html and the size of your homepage, means the server did not find the file and returned something else:
- Django's or Nginx's 404 page.
- An SPA catch-all (
try_files $uri /index.html;) returning the app shell for every path, including missing assets. - A Django view that matches the static path because a catch-all URL pattern is registered last.
The browser reports a MIME error because that is the first check it performs, but the real problem is a missing file. Check:
ls -l /srv/myapp/current/staticfiles/css/ | head
sudo nginx -T 2>/dev/null | grep -A3 "location /static/"
Typical causes: collectstatic did not run for this release, alias points at the wrong directory (or is missing its trailing slash), or the page references a hashed name from a different release. For a SPA fallback, restrict it so /static/ is excluded:
location /static/ {
alias /srv/myapp/current/staticfiles/;
try_files $uri =404;
}
3. Case B: the right file with the wrong type (Nginx)
A 200 with application/octet-stream means Nginx found the file but has no type mapping for the extension. The standard nginx.conf includes the type table in the http block:
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ...
}
Custom or container-provided nginx.conf files sometimes omit the include, so every file becomes application/octet-stream. Restore it.
If only some extensions are affected, the bundled mime.types may be missing them. Older versions lack .mjs (JavaScript modules) and some lack .wasm or .webmanifest. Add them in a second types block at the http level, after the include:
http {
include /etc/nginx/mime.types;
types {
text/javascript mjs;
application/wasm wasm;
application/manifest+json webmanifest;
}
}
At the http level, multiple types blocks are merged. Do not put a types block inside a location: there it replaces the whole table for that location, so every other extension falls back to default_type.
sudo nginx -t && sudo systemctl reload nginx
4. Case B with WhiteNoise
WhiteNoise determines types with Python's mimetypes module plus its own defaults. Add or override mappings in settings:
WHITENOISE_MIMETYPES = {
".mjs": "text/javascript",
".webmanifest": "application/manifest+json",
}
Python's mimetypes also reads system files such as /etc/mime.types when present, so results can differ between a developer machine and a minimal container. Explicit mappings make them consistent.
5. CDNs and object storage
When static files are uploaded to S3 or similar storage, the Content-Type is stored as object metadata at upload time. A wrong type there persists until the object is uploaded again. django-storages sets the type from the file extension during collectstatic; for extensions its lookup does not know, map them explicitly in Python (mimetypes.add_type("text/javascript", ".mjs") in settings) before running collectstatic, then re-upload. CDNs cache the header too; purge after fixing.
6. Keep nosniff
X-Content-Type-Options: nosniff prevents browsers from guessing types, which blocks attacks that upload a file with a harmless extension and get it executed as a script. Turning it off (SECURE_CONTENT_TYPE_NOSNIFF = False) hides the symptom and removes a protection. Fix the type instead.
Verification check:
for f in css/site.4f2a91c3e7b0.css js/app.9b1d2e3f4a5c.js js/module.1a2b3c4d.mjs; do
printf "%-40s " "$f"; curl -sI "https://example.com/static/$f" | grep -i "^content-type" | tr -d '\r'
done
Expect text/css for stylesheets and text/javascript or application/javascript for scripts and modules. Reload the page with the browser's cache disabled; the console errors should be gone.
Explanation
Every static response carries a Content-Type chosen by whatever served it: Nginx from its extension table, WhiteNoise from Python's mimetypes, object storage from stored metadata. Browsers use it to decide whether a response may be applied as a stylesheet or executed as a script, and with nosniff they refuse mismatches outright. An HTML type almost always means the wrong content (a missing file answered by an error page), while a generic binary type means the right content with a missing mapping. Distinguishing the two with one curl saves a lot of guessing.
Edge cases / notes
Source maps and fonts
Missing types for .map, .woff2, or .avif rarely break pages but can cause console warnings or poor caching. Current mime.types files cover them; older distributions may not.
Compressed files
Pre-compressed .gz or .br files served by gzip_static or WhiteNoise keep the original file's type with a Content-Encoding header. If you see application/gzip as the type, the compressed file is being served as a download rather than as an encoded response.
When to automate this
Add a post-deploy check that fetches the stylesheet and script URLs referenced by the homepage and asserts their status and types. It catches both missing files and missing mappings immediately.
Next steps
- For missing files in general, see Django Static Files Not Loading in Production: Fix Guide.
- Configure the app-level server with How to Serve Django Static Files with WhiteNoise.
- Review the Nginx side in How to Configure Nginx for Django in Production.
- For bundler output with module scripts, see Deploy Django with Vite Frontend Assets.
FAQ
Why does the browser say my CSS has MIME type text/html?
The server returned an HTML page instead of the CSS file, usually a 404 page or an SPA fallback. The file is missing or the URL is wrong.
Why are my JavaScript modules served as application/octet-stream?
The server has no type mapping for .mjs. Add text/javascript for mjs in Nginx or WhiteNoise.
Should I disable X-Content-Type-Options nosniff?
No. It protects against content-type confusion attacks. Fix the server's types instead.
Why did adding a types block in a location break other files?
A types block inside a location replaces the whole type table there. Add extra types at the http level instead.