If you write JavaScript long enough, you eventually hit this wall:
- Some files use
import - Some use
require - Something works in Vite but explodes in Node
- A package ships two builds and nobody knows why
That’s not bad luck. That’s you not having a clean mental model of modules.
Let’s fix that.
The real problem modules solve
Here’s the thing: modules aren’t about syntax.
They’re about control.
Without modules:
- Everything leaks into global scope
- File order matters
- Refactoring is scary
- Large apps rot fast
Modules give you:
- Explicit boundaries
- Predictable dependencies
- Isolation
- The ability to reason about code without loading the whole app into your brain
If you don’t care about those, stop reading and go write spaghetti.
ES Modules (ESM): the present and the future
ES Modules are not “new” anymore. They’re the default direction of the JavaScript ecosystem.
Browsers support them. Node supports them. Bundlers are built around them. Deno and Bun don’t even pretend CommonJS is first-class.
What ESM looks like
// math.js
export function add(a, b) {
return a + b
}
export const PI = 3.14159
// app.js
import { add, PI } from './math.js'
add(2, 3)
Nothing fancy. That’s the point.
Rules you must internalize
importandexportonly work at the top level- Imports are static and known ahead of time
- Every file has its own scope
- Strict mode is automatic
- You cannot “half-load” a module
These constraints are not limitations. They’re why ESM works.
Why ESM wins
Static imports mean tools can:
- Analyze dependencies
- Remove unused code (tree-shaking)
- Split bundles intelligently
- Optimize aggressively
This is why modern builds are fast and old ones weren’t.
CommonJS: legacy you still need to survive
CommonJS exists because Node existed before JavaScript had a real module system.
You will encounter it. You should not romanticize it.
What it looks like
// math.js
function add(a, b) {
return a + b
}
module.exports = { add }
// app.js
const { add } = require('./math')
Why it causes problems
CommonJS is:
- Dynamic
- Synchronous
- Runtime-driven
That means:
- Tools can’t safely analyze it
- Tree-shaking is unreliable
- Exports can change at runtime
- Mixing with ESM gets messy fast
You should read CommonJS comfortably.
You should avoid writing new CommonJS unless a tool forces you.
How Node decides what kind of module you’re using
This is where most “why is Node yelling at me” bugs come from.
Node uses signals:
package.json
{
"type": "module"
}
With that:
.jsfiles are ESM
Without it:
.jsfiles are CommonJS.mjsis ESM.cjsis CommonJS
When Node says:
Cannot use import statement outside a module
It’s not being cryptic.
It’s telling you your file is being treated as CommonJS.
Named exports vs default exports (this is not bikeshedding)
Named exports
export function add() {}
export function subtract() {}
import { add } from './math.js'
This is boring. Good.
Benefits:
- Clear public API
- Better autocomplete
- Safer refactors
- Consistent imports across a codebase
Default exports
export default function add() {}
import add from './math.js'
Default exports are fine only when there’s exactly one obvious thing.
Overuse them and your codebase becomes a guessing game.
Dynamic imports: when static isn’t enough
const module = await import('./heavy-feature.js')
Use this when:
- Loading optional features
- Splitting large bundles
- Implementing plugins
- Delaying expensive code
Dynamic imports are how modern apps stay fast without shipping everything upfront.
Browsers, Node, and bundlers are not the same thing
This distinction matters more than most people admit.
Browsers
- Only understand ESM
- Require full file extensions
- No magic resolution
Node
- Supports ESM and CommonJS
- Has its own resolution rules
- Needs configuration
Bundlers
- Rewrite imports
- Resolve extensions
- Polyfill behavior
- Hide complexity
If your code only works because the bundler is babysitting it, you don’t really understand it yet.
Other module formats you should recognize
You’ll see these in old projects or libraries:
- UMD
- AMD
- IIFE
- SystemJS
Know what they are.
Don’t waste time writing them.
Modules are just the beginning
If you’re serious about full-stack JavaScript, modules connect to everything:
- Runtime environments (browser, Node, edge)
- Packaging formats (ESM, CJS, dual builds)
- Transpilers (TypeScript, Babel, SWC)
- SSR vs client rendering
- Monorepos and shared packages
If you don’t know where your code runs, you don’t control it.
Final takeaway
ES Modules aren’t just nicer syntax.
They’re the foundation that made modern JavaScript tooling possible.
Learn them properly, and everything else gets easier.
Ignore them, and you’ll keep fighting mysterious errors you don’t understand.
Next step, if you’re serious:
- TypeScript modules vs JavaScript modules
- Package exports maps
- Dual ESM/CJS libraries
- SSR and module resolution gotchas
If you want, say the word and we’ll tear into those next.