Your Host Is Guessing Your Content-Types

Your static build produces a folder of files. It does not produce a single Content-Type header. Not one.
That header, the thing that tells the browser whether a response is a script, a stylesheet, a manifest, or a blob to download, is chosen by your host at request time. It looks at the file extension, checks a table, and guesses. For .html, .css, and .js, the guess is always right, so nobody ever looks at the table.
Then you ship a .mjs file, a .wasm module, a security.txt with an em dash in it, or an extensionless file under /.well-known/, and the guess is wrong in production and correct on your laptop.
The header is metadata you never wrote
On a dynamic server, the response type is usually set by the code that produced the response. On a static host, there is no code. There is a file on disk and a MIME table somewhere in the host’s stack: nginx’s mime.types, a CDN’s default mapping, a Go or Node static file server’s built-in list.
Those tables differ. They also age. A mapping that exists in your local dev server may not exist in the table your CDN uses, and newer formats are exactly the ones most likely to be missing. When there is no match, most servers fall back to application/octet-stream, which means “some bytes, good luck.”
Your dev server hides all of this. Vite, Astro’s dev server, and friends set types correctly for everything they know about, because they generate the responses themselves. The first time your files meet the production table is in production.
Where a wrong guess actually breaks
Browsers are forgiving about some types and strict about others. The strict cases are the ones that bite.
Module scripts. A <script type="module"> is subject to strict MIME checking. If the response is not a JavaScript MIME type, the browser refuses to execute it and logs an error. Classic scripts get more leeway; modules get none. A host that doesn’t know .mjs serves it as application/octet-stream, and your app does not boot.
Anything under nosniff. If you followed good advice and set X-Content-Type-Options: nosniff, the browser stops trying to rescue mislabeled scripts and stylesheets. A stylesheet that arrives as text/plain is blocked instead of applied. The security header is correct. It just makes the host’s guessing errors fatal instead of silent.
WebAssembly. WebAssembly.instantiateStreaming() requires application/wasm. Serve the module as anything else and it throws a TypeError. The common workaround, fetching into an ArrayBuffer and calling instantiate(), works but gives up streaming compilation to paper over a header.
Text files with non-ASCII characters. text/html without a charset is usually rescued by your <meta charset="utf-8">. Plain text files have no such tag. A robots.txt, llms.txt, or humans.txt served as text/plain with no charset can render as mojibake when someone opens it directly, because the browser falls back to a legacy encoding. RFC 9116 is explicit about this for security.txt: it must be text/plain with charset=utf-8.
Extensionless files. There is no extension to look up. Files like /.well-known/apple-app-site-association usually fall through to application/octet-stream, while the consumer documents application/json. You find out when a platform validator rejects the file, not when your build runs.
Pre-compressed assets. If your build emits app.js.br alongside app.js and the host serves the compressed variant, it needs to send the type of the original file plus a Content-Encoding: br header. A host that treats .br as just another extension sends the wrong type, no encoding, and the browser gets compressed bytes it tries to parse as text.
Download instead of display
The softer failures are still worth fixing. Your RSS feed served as application/octet-stream downloads as a file when someone clicks it, instead of showing up in the browser or handing off to a reader. A PDF with the wrong type downloads instead of opening inline. A .webmanifest served as text/plain may still work in a browser today, but you are relying on leniency nobody promised you.
None of these throw build errors. None show up in Lighthouse. They surface as a support email, or never.
Test the headers, not the files
The fix is not memorizing MIME tables. It is checking what the deployed site actually sends, once per file type, the same way you would check any other output of your pipeline.
Your dist/ folder already tells you which extensions exist. Pick one file per extension, request it from the real host, and compare the answer against what you expect:
// scripts/check-content-types.mjs
import { readdir } from 'node:fs/promises';
import { extname, join } from 'node:path';
const BASE = process.env.BASE_URL; // e.g. https://example.com
const DIST = 'dist';
const expected = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.xml': ['application/xml', 'text/xml', 'application/rss+xml', 'application/atom+xml'],
'.txt': 'text/plain',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.woff2': 'font/woff2',
'.wasm': 'application/wasm',
'.webmanifest': 'application/manifest+json',
};
const files = await readdir(DIST, { recursive: true, withFileTypes: true });
const sample = new Map();
for (const f of files) {
if (!f.isFile()) continue;
const ext = extname(f.name).toLowerCase() || '(none)';
if (!sample.has(ext)) sample.set(ext, join(f.parentPath, f.name));
}
let failed = false;
for (const [ext, path] of sample) {
const url = new URL(path.slice(DIST.length).replaceAll('\\', '/'), BASE);
const res = await fetch(url, { method: 'HEAD' });
const type = res.headers.get('content-type') ?? '(missing)';
const want = [expected[ext] ?? []].flat();
const ok = want.length === 0 || want.some((w) => type.startsWith(w));
const needsCharset = type.startsWith('text/') && !/charset=utf-8/i.test(type);
if (!ok || needsCharset) failed = true;
console.log(`${ok && !needsCharset ? 'ok ' : 'FAIL'} ${ext.padEnd(13)} ${type} ${url.pathname}`);
}
process.exit(failed ? 1 : 0);
Run it against a preview deploy or production after each deploy:
BASE_URL=https://example.com node scripts/check-content-types.mjs
A few deliberate choices in there:
- One file per extension. The host’s decision is keyed on the extension, so one sample covers the whole class. The script stays fast no matter how many pages you have.
- Unknown extensions print but don’t fail. The first run is an inventory. You will likely find an extension you forgot you were shipping. Add it to the table once you’ve decided what it should be.
text/*withoutcharset=utf-8fails. This is the check that catches the mojibake case before a reader does.- Extensionless files show up as
(none). That line is your reminder to look at them by hand.
The expected table encodes your decisions, not a universal truth. application/javascript is still common and still works; the current standard says text/javascript. Pick one, write it down, and let the check hold the host to it.
Fix it at the host, by path
When the check fails, don’t rename files to dodge the table. Most static hosts let you set response headers by path pattern, and a Content-Type set there generally wins over the default guess. The same config file that holds your cache rules and security headers should hold the types the host gets wrong:
headers:
- path: /*.mjs
name: Content-Type
value: text/javascript
- path: /*.wasm
name: Content-Type
value: application/wasm
- path: /.well-known/security.txt
name: Content-Type
value: text/plain; charset=utf-8
- path: /.well-known/apple-app-site-association
name: Content-Type
value: application/json
Hosts vary in how path-based header rules interact with their defaults, so don’t take the config file’s word for it. That is exactly what the check script is for: change the rule, deploy, rerun, read the actual header.
The build is not the whole output
Static sites get credit for being simple because the output is “just files.” It isn’t. The output is files plus the headers the host attaches to them, and the headers are the half you didn’t write and never tested.
You already check that your pages exist and your links resolve. Check that the host calls each file what it is. It is a dozen lines of script, it runs in seconds, and it turns a production-only failure into a red line in CI.

