A Module Is a File With Its Own Scope
Before ES modules existed, every <script> tag dumped its variables into the global namespace, and load order decided what saw what. ES modules fix that by making each file its own scope. Nothing leaks out unless you explicitly export it. Nothing comes in unless you explicitly import it.
Two files, one exporting, one importing:
add and multiply live in math.js. They only become visible in main.js because of the import. Nothing else in math.js — helpers, constants, whatever — is reachable from the outside.
Two rules follow from this and are worth internalizing early:
- Modules run in strict mode automatically. No
'use strict'needed. - Top-level
thisisundefined, not the global object.
Named Exports: Export As You Go
The most common form. Stick export in front of any function, class, const, or let and it becomes part of the module's public surface:
The names in the braces have to match the exported names exactly — import { circlearea } would fail. If a name clashes with something you already have, rename on import with as:
You can also list exports at the bottom of a file instead of inline, which some people prefer for a clear "public API" section:
Both styles produce the same result. Pick one and stay consistent within a project.
Default Exports: One Per Module
A module can also have a single default export. Defaults are for files that really have one main thing — a component, a class, a config object:
Three things to notice:
- No braces around
logon the import side. - The imported name is whatever you want.
import shout from './logger.js'would work identically. - Only one default export per file. Try to add a second and the file won't parse.
Named and default exports can coexist:
Default first, then named in braces. The order is fixed.
Which should you use? Named exports are easier to refactor — renaming across a codebase is a single find-and-replace because every import uses the same name. Defaults are flexible but let every caller pick a different name, which makes grep harder. Most modern style guides lean toward named exports and reserve default for genuinely single-purpose modules.
Import Everything, Re-Export, Side Effects
A few more import shapes you'll run into.
Grab every named export into one namespace object:
Re-export from another module without importing into the current scope:
That pattern is how library entry points gather pieces from internal files into one public surface.
And finally, an import with no bindings at all — for modules whose only job is side effects (polyfills, CSS-in-JS, registering handlers):
The file runs once; nothing is imported by name.
Imports Are Static and Live
Two properties of import that occasionally surprise people.
Static. import declarations are resolved before any of your code runs. You can't put one inside an if, a function, or a try. The path has to be a string literal, not a variable. That's what lets tools analyze imports without executing the code — bundlers, type checkers, tree-shakers all rely on it.
// Not allowed — SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
If you need conditional loading, use import() (coming up next).
Live. An imported binding is a read-only reference back to the export, not a snapshot. If the exporting module reassigns the value, importers see the new value:
You also can't reassign an import on the consumer side — count = 5 in main.js would throw. Imports are read-only views.
Dynamic import() for Loading on Demand
When you need to decide at runtime whether to load a module — heavy features, route-based code splitting, conditional polyfills — use import() as a function. It returns a promise that resolves to the module's exports:
Because it's a regular function call, you can:
- Await it inside an
asyncfunction. - Pass a variable as the path.
- Use it inside an
ifortry/catch.
Destructuring the resolved object works the same as a static import:
default is the key for the default export when you destructure. Rename it to whatever you want.
The practical use cases are code-splitting (only ship the chart library when the user clicks "show chart"), feature-detection polyfills, and plugins that are discovered at runtime.
Running ES Modules: Browsers and Node
The syntax is the same everywhere; the thing that changes is how the runtime finds and loads the file.
In the browser, mark the entry script as a module:
<script type="module" src="./main.js"></script>
With type="module", the browser respects import/export, runs the code in strict mode, and defers execution until the HTML is parsed. Paths must be relative (./, ../) or absolute URLs — bare specifiers like import 'lodash' don't work without an import map or a bundler.
In Node, there are two ways to opt in:
- Name the file
.mjs, or - Set
"type": "module"in the nearestpackage.json, which makes every.jsfile a module.
Node also requires full paths with extensions: import './utils.js', not import './utils'.
// package.json
{
"type": "module",
"main": "./index.js"
}
Both environments require explicit extensions in native ESM. Bundlers (Vite, webpack, esbuild) will resolve extensionless paths for you during development — convenient, but relying on it means your source won't run without the build step.
Common Pitfalls
A few things that trip people up:
- Forgetting
type="module"in the browser. Without it,<script>runs as a classic script andimportis a syntax error. - Missing file extensions in Node.
import './utils'fails;import './utils.js'works. Bundlers hide this, native runtimes don't. - Expecting
__dirnameorrequirein an ES module. Those are CommonJS-only. In ESM, useimport.meta.urland convert it when you need a path. - Circular imports that touch values before they're ready. Two modules importing each other is legal, but reading an export that hasn't been assigned yet gives you
undefined. Structure your code so the cycle isn't hit during initialization, or break it up. - Trying to conditionally
import. The staticimportstatement doesn't allow it. Use dynamicimport()for anything runtime-dependent.
Next: CommonJS vs ESM
ES modules are the standard, but plenty of Node code in the wild still uses CommonJS — require, module.exports, and a different set of rules about when code runs. Knowing both, and how they interop, is the next page.
Frequently Asked Questions
How do I use ES modules in JavaScript?
Export values from one file with export or export default, then pull them into another file with import. In the browser, load the entry file with <script type="module" src="main.js"></script>. In Node, either use the .mjs extension or set "type": "module" in package.json.
What's the difference between default and named exports?
A module can have many named exports (export function foo() {}) but only one default export (export default ...). Named exports have to be imported with the exact same name in braces: import { foo } from './x.js'. A default export can be imported under any name: import whatever from './x.js'.
What is dynamic import() in JavaScript?
import() called as a function returns a promise that resolves to the module's exports. Unlike the static import statement, it runs at call time, so you can load code conditionally or on demand. It's how you implement code-splitting and lazy loading.
Do I need a file extension in import paths?
In native ES modules — browsers and Node's ESM loader — yes. You have to write ./utils.js, not ./utils. Bundlers like Vite and webpack are more forgiving and will resolve extensionless paths for you, but relying on that makes your code non-portable.