Modules — import, export and the CommonJS Split
IntermediateES Modules are the standard: static import/export, resolved before execution, with live bindings. CommonJS require() is the older Node system, and the two do not mix cleanly — which is the source of most "cannot use import outside a module" errors.
Overview
For most of its life JavaScript had no module system, so the ecosystem invented several. Node standardised on CommonJS — require() and module.exports — and browsers eventually got ES Modules, which are now the standard everywhere. ESM imports are static: the engine reads them before running any code, which is what makes tree-shaking and circular-dependency detection possible. Understanding which system a file is in, and why a package can break when you mix them, saves hours of confusing errors.
Named and Default Exports
Named exports are the default choice: they are explicit, renameable at the import site, and survive refactoring better because the name is checked.
// utils.js — named exports
export const formatPrice = paise => `₹${(paise / 100).toFixed(2)}`
export function slugify(s) { ... }
export { internalName as publicName }
// importing
import { formatPrice, slugify } from './utils.js'
import { formatPrice as money } from './utils.js'
import * as utils from './utils.js'
// default — one per module, name chosen by the importer
export default function Button(props) { ... }
import Button from './Button.jsx'
import AnythingAtAll from './Button.jsx' // legal, and a downsideStatic Analysis and Live Bindings
Imports are hoisted and resolved before execution, and they are live views of the exported binding rather than copies.
// Imports hoist — this works:
greet()
import { greet } from './greet.js'
// Bindings are live, not snapshots:
// counter.js
export let count = 0
export const bump = () => count++
// main.js
import { count, bump } from './counter.js'
console.log(count) // 0
bump()
console.log(count) // 1 — the import sees the update
// Dynamic import when you need it at runtime — returns a promise
const { default: Chart } = await import('./Chart.js')ESM vs CommonJS in Node
This is the practical friction. Node decides which system a file uses by extension and by package.json.
// CommonJS — synchronous, dynamic, older
const express = require('express')
module.exports = { handler }
// ESM — static, async, standard
import express from 'express'
export { handler }
// Which one Node uses:
// .mjs -> always ESM
// .cjs -> always CommonJS
// .js + "type": "module" -> ESM
// .js + no type field -> CommonJS
// ESM can import CommonJS:
import express from 'express' // works
// CommonJS cannot require ESM:
const mod = require('./esm-only.js') // ERR_REQUIRE_ESMKey Points to Remember
- 1ES Modules are static — imports are resolved before execution, which enables tree-shaking
- 2Prefer named exports: they are explicit, checked, and refactor better than defaults
- 3Imported bindings are live views of the export, not copies taken at import time
- 4Node picks ESM or CommonJS by file extension and package.json "type" — this is what causes ERR_REQUIRE_ESM
- 5ESM can import CommonJS but not the reverse; use dynamic import() to load a module at runtime
Interview Questions
Sign in to ask AriaWhat is the difference between a named export and a default export?
Why can ES Modules be tree-shaken when CommonJS generally cannot?
You get "ERR_REQUIRE_ESM" in a Node project. What has happened and how do you fix it?
Ask Aria about Modules — import, export and the CommonJS Split
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.