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.

Wrong type, two causesBrowserrequests site.cssServer404 page or fileContent-Typetext/html or octetBrowser refusesnosniff is stricttext/html usually means a missing file; octet-stream means a missing type mapping.
  1. Request the file directly with curl -sI and read the status code and Content-Type.
  2. 404 or 200 with text/html: the file is not where the URL points, and an error page or SPA fallback was returned. Fix collectstatic, STATIC_ROOT, and the Nginx alias (see the static files guide).
  3. 200 with application/octet-stream or text/plain: Nginx is missing include mime.types; or the extension is not in the table (often .mjs, .wasm, .webmanifest).
  4. Add missing types in Nginx with an extra types block at the http level, or via WHITENOISE_MIMETYPES when WhiteNoise serves the files.
  5. 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)"
what came back?What is in the response body?an HTML pageCase A: file missinga 404 or fallback pagethe right fileCase B: wrong typeserver MIME configtext/html for a .js file is almost always Case Adjango-deployment.com
curl the URL and look at the first line of the body.

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;
    # ...
}
where Nginx gets Content-Typehttp {include /etc/nginx/mime.types;default_typeapplication/octet-stream;types { application/javascriptmjs; }}1231The extension-to-type table; missing it breaks CSS and JS.2Used for any unknown extension.3Add types the stock table lacks.A types block inside a location replaces the whole table theredjango-deployment.com
Test with curl -I after nginx -t and a reload.

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.

keep nosniff, fix the typeRemove nosniff✕browser guesses the type again✕uploads can run as scripts✕hides the misconfigurationFix the Content-Type✓correct types from the server✓nosniff stays as protection✓missing files show as 404nosniff only exposes the bug; it did not cause itdjango-deployment.com
SecurityMiddleware adds nosniff by default in current Django.

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

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.

Keep going