JavaScript ES Modules Explained: import, export, and the Modern Module System
ECMAScript Modules — ESM, or just "ES modules" — are JavaScript's standard module system. They replaced the older CommonJS (require/module.exports) and AMD systems with a syntax baked into the language. In 2026, every modern runtime (browsers, Node 22+, Deno, Bun, edge) speaks ESM natively, and tooling defaults to it.
This lesson covers the import/export syntax, named vs default exports, dynamic imports, the differences from CommonJS, top-level await in modules, import maps, and the practical conventions that have settled into the ecosystem.
The basics: export and import #
A module is a file that exports values and optionally imports from other modules.
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// app.js
import { add, multiply, PI } from './math.js';
console.log(add(PI, multiply(2, 3))); // 9.14159
Three pieces:
export— marks a value as available for other modules to import. Multiple per file.import { name } from 'path'— pulls named exports into the current scope.- The file path — relative (
./math.js) or bare specifier (react,@scope/lib) resolved by the host.
Named exports #
The most common form. Any number per file:
// Multiple at declaration
export const x = 1;
export const y = 2;
export function fn() {}
// All in one export block
const a = 1, b = 2;
function c() {}
export { a, b, c };
// With renaming
export { a as alpha, b as beta };
Imported by exact name:
import { x, y, fn } from './lib.js';
import { a as alpha } from './lib.js'; // rename on import
import * as lib from './lib.js'; // namespace import — lib.x, lib.y, ...
Default exports #
One per file. The single "main" export:
// User.js
export default class User {
constructor(name) { this.name = name; }
}
// app.js
import User from './User.js'; // no braces — name is yours to choose
import Person from './User.js'; // works too — local name is free
Mixing both works:
// api.js
export default function createClient() {}
export const VERSION = '1.0.0';
import createClient, { VERSION } from './api.js';
The ecosystem split: named exports are increasingly preferred in modern libraries because they're refactor-friendly (renaming in one place reveals all users), tree-shake-friendly, and IDE-friendly (auto-import picks the right name). Single-default exports are common for classes, components, and "the thing" each file is about.
Re-exporting #
For barrel files (index.js that re-exports from sibling files):
export { add, multiply } from './math.js';
export { format } from './string.js';
export * from './utils.js'; // re-export everything
export { default as User } from './User.js'; // re-export a default as named
Useful for packages — one entry point gathers all the publicly-exported names.
Caveat: barrel files can hurt tree-shaking if the bundler can't statically prove which exports are actually used. Modern bundlers (esbuild, Vite, Rollup) handle this well, but for hot-path performance, importing directly from the file is sometimes faster.
Dynamic imports #
The import keyword can also be used as a function — returns a Promise:
const { default: Modal } = await import('./Modal.js');
Modal.show();
Uses:
- Code splitting — load a route's code only when the user navigates to it.
- Conditional features — load a feature module only when a flag is enabled.
- Heavy libraries — defer loading until the user clicks the button that needs it.
Bundlers (Vite, Webpack, esbuild) split dynamic imports into separate chunks automatically.
Top-level await #
In ESM (not CommonJS), await works at the module's top level:
// config.js
const raw = await fetch('/config.json');
export const config = await raw.json();
Modules that import ./config.js wait for these awaits to settle before their own code runs. Useful for one-shot setup (database connections, config loading, decryption) without wrapping everything in a main() function.
In ES modules, imports are always sync-shaped at the call site — you don't await an import. The module system handles the wait internally.
ESM vs CommonJS #
In Node (and tools that emulate it), CommonJS uses require/module.exports:
// CommonJS
const fs = require('node:fs');
function read(p) { return fs.readFileSync(p, 'utf8'); }
module.exports = { read };
The key differences from ESM:
| Aspect | CommonJS | ESM |
|---|---|---|
| Syntax | require(), module.exports |
import, export |
| Loading | Synchronous, runtime | Static, parse-time |
| Top-level await | No | Yes |
| Tree-shaking | Hard | Easy |
| Default extension | .js (in CJS projects) |
.js / .mjs (in ESM projects) |
__dirname / __filename |
Available | Use import.meta.url |
| Circular imports | Loose (may see partial values) | Strict (bindings are live) |
For a Node project, pick one via package.json:
{ "type": "module" } // makes .js files ESM by default
New projects default to ESM. Existing CJS projects gradually migrate.
ESM in the browser #
Load a module with <script type="module">:
<script type="module" src="./app.js"></script>
Key things browser modules do for you:
- Always deferred. Wait for HTML parse before running. No
deferattribute needed. - Strict mode by default.
thisisundefined, not the global object.- CORS-checked — modules loaded cross-origin need permissive CORS headers.
- Cached by URL — every import of the same URL gets the same module instance.
For production, bundlers produce optimized ESM bundles that browsers and runtimes can load.
Import paths and bare specifiers #
Four styles:
import x from './local.js'; // relative
import x from '/abs.js'; // absolute, from server root
import x from 'https://cdn.x/lib.js'; // URL (Deno, browser, sometimes Bun)
import x from 'lodash'; // bare — resolved by host (node_modules, import map)
Import maps #
Bare specifiers in browsers need an import map:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash",
"@/": "/src/"
}
}
</script>
Then import _ from 'lodash' works in the browser. Most production sites still ship a bundled build instead, but import maps make zero-build development setups viable.
Live bindings #
ESM imports are live bindings, not values. The imported name reflects the current value of the export — even if it changes:
// counter.js
export let count = 0;
export function increment() { count++; }
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — sees the update
This is different from CommonJS, where you'd get a snapshot of the value at require time.
In practice, most exports are functions or const objects — the live-binding distinction rarely surfaces.
import.meta #
A built-in object with module-specific metadata:
import.meta.url; // the URL of this module (e.g. 'file:///path/to/app.js')
import.meta.resolve(s); // resolve a specifier relative to this module
Replaces __dirname and __filename from CommonJS. To get a directory path in Node:
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
A few conventions #
- Use named exports by default. Default exports for component files, classes that are the entire purpose of the file.
- File extensions in import paths — always include
.js(or.mjs/.ts) in modern Node ESM. Browsers require them. Bundlers can sometimes infer them. - One responsibility per file. Modules should be small and focused.
- Avoid side effects on import. A file with
console.log('loaded')at the top makes mocking and testing harder. - Use barrel files sparingly. Useful for public API, but verbose and tree-shake-unfriendly when overused.
A summary #
- ESM is the standard. Every modern runtime supports it natively.
exportmarks a value as importable.importbrings it in.- Named and default exports can coexist. Prefer named for libraries.
- Dynamic
import()returns a Promise — for code splitting and lazy loading. - Top-level
awaitworks in ESM, not CommonJS. - Bindings are live, not snapshots.
import.metareplaces CommonJS metadata (__dirname, etc.).- Browsers, Node 22+, Deno, Bun, and edge runtimes all speak ESM.
That's the full module system in one page. Everything else is bundler configuration.
What's next #
That completes Module 6 and the entire Modern JavaScript Tutorial path. You now have:
- Module 1: Foundations — types, variables, operators, conditionals, loops, basic functions
- Module 2: Functions Deep Dive — closures, scope, this, higher-order, pure functions
- Module 3: Objects, Arrays & Iteration — including spread, destructuring, descriptors
- Module 4: Classes & OOP — including private fields and composition
- Module 5: Asynchronous JavaScript — callbacks, Promises, async/await, the event loop
- Module 6: Modern JS — modules, ES2024 features, coercion, memory
- Module 7: Browser & DOM — selection, events, forms, fetch, storage
From here, the natural next directions are framework-specific paths (Angular, React, Vue), specialized tracks like RxJS or TypeScript, or topic deep dives. Each builds on the foundation you just laid.
Try it yourself #
Live bindings are the easiest ESM feature to feel. Predict the output of these two files:
// counter.js
export let n = 0;
export const bump = () => n++;
// main.js
import { n, bump } from './counter.js';
console.log(n);
bump();
console.log(n);What does main.js print?
js_sandboxOutput:0 (initial value)1 (after bump — the imported n reflects the live value, not a snapshot)You can’t reassign
n from main.js (n = 2 would throw — imports are read-only). But you CAN see the exporter’s mutation. That’s the difference between ESM’s live bindings and CommonJS’s value snapshots.This property is what lets a module export a value that callers see updates to without explicit notification — useful for module-level state, less footgun than it sounds when most exports are functions and consts anyway.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


