Modules in the Browser
Browsers have shipped native ES module support since 2017, which means you can use import and export directly in the page without a bundler. The entry point is a single attribute: <script type="module">. That one attribute changes how the script is fetched, parsed, and scoped, so understanding its defaults is the difference between code that “just works” and a confusing CORS or this-is-undefined surprise.
Loading a module script
A module script is declared with type="module". You can point it at an external file or write the module inline.
<!-- External module -->
<script type="module" src="/js/app.js"></script>
<!-- Inline module -->
<script type="module">
import { greet } from '/js/greet.js';
greet('World');
</script>
Inside app.js, you write standard ESM. The browser resolves each import by issuing its own network request, following the dependency graph until everything is loaded.
// /js/greet.js
export function greet(name) {
console.log(`Hello, ${name}!`);
}
Output:
Hello, World!
How module scripts differ from classic scripts
The behavior of type="module" differs from a classic <script> in several important ways. These are not opt-in flags — they are always on for module scripts.
| Behavior | Classic <script> | <script type="module"> |
|---|---|---|
| Execution timing | Blocks parsing (unless defer/async) | Always deferred |
| Strict mode | Sloppy by default | Always strict |
| Top-level scope | Shares global scope | Per-module scope |
this at top level | window | undefined |
| Cross-origin fetch | Allowed | Requires CORS |
| Re-execution | Runs every time it appears | Runs once per URL |
Deferred by default
A module script behaves as though it has the defer attribute. It downloads in parallel with HTML parsing but does not execute until the document has been fully parsed, and multiple module scripts execute in document order. You almost never need to wait for DOMContentLoaded inside a module — the DOM is already available.
// Runs after the document is parsed; the element exists.
document.querySelector('#app').textContent = 'Ready';
Adding
asyncto a module script overrides the deferred behavior: it will execute as soon as it (and its dependencies) finish downloading, without waiting for the rest of the document or preserving order.
Strict and scoped
Every module runs in strict mode automatically — no "use strict" directive needed. Top-level variables are scoped to the module rather than leaking onto window, and this at the top level is undefined instead of the global object. Sharing state between modules is done explicitly through import/export, not through globals.
// In a module:
const apiKey = 'abc123'; // NOT window.apiKey
console.log(this); // undefined
CORS applies
Module scripts are fetched with CORS semantics. A cross-origin module must be served with an appropriate Access-Control-Allow-Origin header, and loading from file:// is blocked — you need an HTTP server even for local development. Spin one up quickly:
npx serve .
# or
python3 -m http.server 8080
Module specifiers
The string you pass to import is the module specifier. In the browser, specifiers must be a valid URL or a relative/absolute path — a leading /, ./, or ../, or a full https:// URL. A bare specifier like import _ from 'lodash' does not work natively, because the browser has no idea where lodash lives.
import { greet } from './greet.js'; // relative — OK
import { config } from '/js/config.js'; // absolute path — OK
import dayjs from 'https://esm.sh/dayjs'; // full URL — OK
import _ from 'lodash'; // bare — fails without an import map
Always include the file extension (
./greet.js, not./greet). The browser does not guess extensions the way Node or bundlers sometimes do.
Import maps
An import map lets you use bare specifiers in the browser by mapping a name to a URL. Declare it with <script type="importmap"> and it must appear before any module script that relies on it.
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash-es",
"dayjs": "https://esm.sh/[email protected]",
"@utils/": "/js/utils/"
}
}
</script>
<script type="module">
import { capitalize } from 'lodash';
import dayjs from 'dayjs';
import { formatDate } from '@utils/date.js'; // resolves to /js/utils/date.js
console.log(capitalize('hello'));
console.log(formatDate(dayjs()));
</script>
Keys that end in / define a prefix mapping, so @utils/date.js resolves to /js/utils/date.js. This is how you alias whole folders or pin specific dependency versions without rewriting every import in your source files.
A complete self-contained example
<!doctype html>
<html lang="en">
<body>
<p id="out"></p>
<script type="module">
// Top-level await is allowed inside module scripts.
const greet = (name) => `Hello, ${name}!`;
const messages = ['Ada', 'Linus', 'Grace'].map(greet);
document.querySelector('#out').textContent = messages.join(' ');
</script>
</body>
</html>
Output:
Hello, Ada! Hello, Linus! Hello, Grace!
Best Practices
- Always serve modules over HTTP(S);
file://is blocked by CORS rules. - Include explicit file extensions in every specifier — the browser will not add them for you.
- Rely on the built-in deferred behavior instead of wiring up
DOMContentLoaded. - Use an import map to manage bare specifiers and pin dependency versions in one central place.
- Place the import map before any module script that depends on it.
- Provide a
<script nomodule>fallback only if you must support legacy browsers; otherwise it is unnecessary in 2026.