If you copy the index.html and the 6-line Node server from this article, you'll have a multi-file Vanilla JS app running with native ES modules, no npm install, no Vite, no bundler — and you'll know exactly why file:// silently breaks import, why python -m http.server serves your .mjs as text/plain, and how to fix both in under 5 minutes.
I rebuilt a small internal dashboard this way last month after ripping out a 240-package node_modules (1.1 GB on disk) that existed only to concatenate 4 files. Cold npm run dev startup went from 3.8s to 0s. Here's the exact setup and the 3 errors that ate my first hour.
TL;DR: The 3 rules for build-tool-free ES modules in 2026
-
You must serve over HTTP. Opening
index.htmlfromfile://throws a CORS error on everyimport— by spec, not by accident. -
The server must send
Content-Type: text/javascript(orapplication/javascript) for.js/.mjs. A wrong MIME type makes the browser refuse the module with a strict MIME checking error, even though the file is right there. -
importspecifiers need a path prefix.import './util.js'works;import 'util.js'is a "bare specifier" and fails unless you add an import map.
Everything below is just these three rules in detail, with copy-pasteable code.
The minimal index.html and app.js that run with native ES modules
No package.json. Two files. The type="module" attribute is the entire "build system."
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>No-build Vanilla JS</title>
</head>
<body>
<button id="go">Fetch users</button>
<ul id="out"></ul>
<!-- The only thing that matters: type="module" -->
<script type="module" src="./app.js"></script>
</body>
</html>
// app.js
import { fetchUsers } from './api.js';
import { renderList } from './dom.js';
const btn = document.getElementById('go');
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const users = await fetchUsers(); // top-level await also works in modules
renderList(document.getElementById('out'), users);
} finally {
btn.disabled = false;
}
});
// api.js
export async function fetchUsers() {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// dom.js
export function renderList(ul, items) {
ul.replaceChildren(
...items.map((u) => {
const li = document.createElement('li');
li.textContent = `${u.name} — ${u.email}`;
return li;
})
);
}
Four files, three of them ES modules importing each other. type="module" gives you, for free: deferred execution (the script runs after the DOM is parsed, so no DOMContentLoaded wrapper needed), strict mode by default, top-level await, and per-module scope (no accidental globals). That's a meaningful chunk of what people reach for Webpack to get.
Note the ./ in every import. Drop it and you get Failed to resolve module specifier "api.js". The browser reserves bare specifiers (import x from 'lodash') for import maps, which I'll cover at the end.
Trap #1: opening the file directly throws a CORS error (the file:// problem)
You'd think double-clicking index.html would work. It doesn't. In Chrome 130+ and Firefox you get:
Access to script at 'file:///C:/app/app.js' from origin 'null'
has been blocked by CORS policy.
The reason: ES modules are always fetched with CORS semantics, and the file:// protocol has a null origin, so the cross-origin check can never pass. A classic <script src> (no type="module") would have loaded fine — which is exactly why the error confuses people. The module-ness is what triggers CORS. There is no flag to disable this in a normal browser; you have to serve over HTTP.
The smallest fix most people know is python -m http.server 8000. It works for the CORS part... and immediately drops you into Trap #2.
Trap #2: python -m http.server serves .mjs as text/plain and the browser refuses it
Run python -m http.server 8000, rename api.js to api.mjs, and you'll hit:
Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/plain".
Strict MIME type checking is enforced for module scripts per HTML spec.
Plain .js usually survives because Python's mimetypes table maps it to text/javascript on most systems — but .mjs is missing from older Python's table and falls back to text/plain, and the browser will not execute a module unless the MIME type is in the JavaScript family. Unlike classic scripts, modules do strict MIME checking. This is the single most common "but the file is right there!" failure.
Don't fight Python's mimetypes registry. Use a 6-line Node server that sets the header correctly and works identically on Windows, macOS, and Linux:
// serve.mjs — run with: node serve.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
createServer(async (req, res) => {
// strip query string, prevent ../ path traversal
const urlPath = decodeURIComponent(req.url.split('?')[0]);
const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
const file = join(process.cwd(), safe === '/' ? 'index.html' : safe);
try {
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404');
}
}).listen(8000, () => console.log('http://localhost:8000'));
Open http://localhost:8000 and the app runs. This server has zero dependencies — it's pure node: built-ins, so there's no npm install and nothing to keep up to date. The TYPES map is the whole point: it guarantees every .js and .mjs goes out as text/javascript, which is what the strict module loader demands. (Yes, this server is itself a .mjs module — Node has supported import natively since v14, so no config there either.)
If you're on Python 3.11+ and want to stay in Python, python -m http.server did finally get .mjs → text/javascript in the table — but I've been burned by mixed Python versions across machines, so I default to the Node server above for reproducibility. On my Windows 11 box the Python 3.10 that shipped with an old toolchain still serves .mjs as text/plain; the Node version doesn't care.
Trap #3: caching makes edits "not apply" — and why I stopped renaming to .mjs
The second hour-eater: you edit dom.js, reload, and the old behavior persists. Module scripts are aggressively cached by the browser, and a 304/from disk cache will happily reuse the previous version during dev. Two fixes that actually stick:
- In DevTools → Network, check Disable cache (only while DevTools is open). This is the one I use.
- Or add a no-store header in the dev server. Insert this before
res.end(body):
res.setHeader('Cache-Control', 'no-store');
And a recommendation that contradicts half the tutorials online: just use .js, not .mjs, in the browser. The .mjs extension is a Node-land convention for telling Node "this is a module." In the browser, what makes a file a module is type="module" on the <script> and the import/export syntax — the extension is irrelevant to the parser and only matters for the server's MIME guess. Using .js everywhere means you never trip the .mjs-missing-from-mimetypes bug in the first place. I only reach for .mjs in files that Node also executes directly (like serve.mjs above).
When you outgrow relative paths: import maps replace 80% of why I used a bundler
The one real feature a bundler gave me that native modules seemed to lack was bare specifiers — import { z } from 'zod'. Import maps, shipping in all evergreen browsers since 2023, close that gap with no tooling:
<script type="importmap">
{
"imports": {
"zod": "https://cdn.jsdelivr.net/npm/zod@3.23.8/+esm",
"@/": "/src/"
}
}
</script>
<script type="module">
import { z } from 'zod'; // bare specifier, resolved by the map
import { renderList } from '@/dom.js'; // your own alias, like Webpack's @
const Schema = z.object({ name: z.string() });
console.log(Schema.safeParse({ name: 'ok' }).success); // true
</script>
The @/ → /src/ mapping is the same path-alias ergonomics people install vite-tsconfig-paths for, except it's a 4-line JSON block the browser understands natively. The importmap <script> must appear before any module script that uses it.
The honest limits: when no-build actually costs you
I'm not going to tell you to delete Vite from your day job. Native modules have real edges:
- No tree-shaking or minification. For a 50-file app you'll ship 50 HTTP requests and unminified source. Over HTTP/2 this is fine for internal tools and prototypes; for a public marketing site with a Lighthouse budget, a bundler still wins.
-
No TypeScript, JSX, or
.vuetransforms. The browser runs JS, full stop. If you want types, you're back to a build step (or you use JSDoc +// @ts-check, which I do for small projects). - No HMR. You get full reloads. With "Disable cache" on, that's a ~200ms penalty per save, which I'll trade for a 0-dependency, 0-startup setup on a dashboard.
The sweet spot is exactly this: internal tools, admin panels, demos, teaching repos, and the prototyping phase before complexity justifies tooling. For everything I described, the entire "toolchain" is one 30-line serve.mjs and a habit of writing ./ in front of imports.
If you build Vanilla JS apps like this regularly, Eloquent JavaScript (3rd ed.) and JavaScript: The Definitive Guide (7th ed.) on Amazon are the two references I keep open for module-system edge cases — both cover the ES module spec in depth. And if you want a real domain to serve this from instead of localhost:8000, a cheap VPS or shared host with HTTP/2 (the kind A8-listed providers rent for a few hundred yen/month) makes the 50-request no-bundle approach perfectly fast in production.
Got a Failed to resolve module specifier error I didn't cover? Drop the exact string in the comments — 9 times out of 10 it's a missing ./ or a MIME header, and I'll tell you which.
Top comments (0)