Toolmingo
Guides9 min read

JavaScript Modules: Complete Guide to import, export, and CommonJS

Master JavaScript modules: ES Modules (import/export), CommonJS (require/module.exports), dynamic imports, named vs default exports, tree shaking, and module patterns with practical examples.

JavaScript has two major module systems: ES Modules (the modern standard) and CommonJS (the Node.js original). Understanding both — and when to use each — is essential for every JavaScript and Node.js developer.


Quick-reference table

Feature ES Modules (ESM) CommonJS (CJS)
Syntax import / export require() / module.exports
Loading Static, compile-time Dynamic, runtime
Top-level await ✅ Supported ❌ Not supported
Tree shaking ✅ Yes (bundlers) ❌ Harder
Browser native ✅ Yes (type="module") ❌ No
Node.js .mjs or "type":"module" .cjs or default
Interop import cjs from 'pkg' works createRequire needed for ESM

ES Modules — named exports

Named exports let a module expose multiple values. Each must be imported by its exact name.

// math.js
export function add(a, b)      { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;
// app.js
import { add, PI } from './math.js';

console.log(add(2, 3)); // 5
console.log(PI);         // 3.14159

You can rename on import with as:

import { add as sum, PI as pi } from './math.js';
console.log(sum(1, 2)); // 3

ES Modules — default export

Each module can have one default export. It can be imported under any name.

// greet.js
export default function greet(name) {
  return `Hello, ${name}!`;
}
// app.js
import greet from './greet.js';
import sayHello from './greet.js'; // same thing, different name

console.log(greet('World'));    // Hello, World!
console.log(sayHello('Alice')); // Hello, Alice!

Mixing named and default in one module:

// utils.js
export default class EventEmitter { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export const VERSION = '1.0.0';
import EventEmitter, { debounce, VERSION } from './utils.js';

Export declaration styles

There are two ways to mark exports:

// Inline (at declaration)
export const BASE_URL = 'https://api.example.com';
export function fetchUser(id) { /* ... */ }

// Grouped (at the end — easier to see what's public)
const BASE_URL = 'https://api.example.com';
function fetchUser(id) { /* ... */ }
function _validateId(id) { /* private, not exported */ }

export { BASE_URL, fetchUser };

The grouped style is preferred in large modules because it creates a clear "public API" section at the bottom.


Re-exporting (barrel exports)

A common pattern is an index.js that re-exports from multiple files, giving consumers a single import point:

src/
  utils/
    string.js
    number.js
    date.js
    index.js   ← barrel file
// utils/index.js
export { capitalize, truncate } from './string.js';
export { clamp, roundTo }       from './number.js';
export { formatDate, diffDays } from './date.js';
// Anywhere in the app
import { capitalize, formatDate } from './utils/index.js';

You can also re-export a default:

export { default as Logger } from './logger.js';

Dynamic imports

Static import at the top of a file runs at load time. Dynamic import (import()) is a function that loads a module on-demand and returns a Promise — ideal for code splitting and conditional loading.

// Load a heavy library only when needed
async function loadChart() {
  const { Chart } = await import('./chart.js');
  return new Chart(data);
}

// Conditional feature (tree-shaking won't apply here)
const locale = navigator.language.startsWith('fr') ? 'fr' : 'en';
const messages = await import(`./locales/${locale}.js`);

React lazy loading uses dynamic import under the hood:

const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

Namespace imports

Import everything from a module as a single object:

import * as MathUtils from './math.js';

console.log(MathUtils.add(1, 2)); // 3
console.log(MathUtils.PI);         // 3.14159

Useful when consuming third-party modules where you want the package name as a namespace, or when you don't control what gets added to the module.


CommonJS — require / module.exports

CommonJS is the original Node.js module system. Files are evaluated synchronously, and require() can appear anywhere (even inside conditionals).

// math.js
function add(a, b)      { return a + b; }
function subtract(a, b) { return a - b; }
const PI = 3.14159;

module.exports = { add, subtract, PI };
// app.js
const { add, PI } = require('./math');

console.log(add(2, 3)); // 5

Single-function export:

// greet.js
module.exports = function greet(name) {
  return `Hello, ${name}!`;
};
const greet = require('./greet');
console.log(greet('World')); // Hello, World!

Conditional require (common for plugins):

let config;
if (process.env.NODE_ENV === 'production') {
  config = require('./config.prod');
} else {
  config = require('./config.dev');
}

CommonJS vs ES Modules in Node.js

Node.js supports both. How Node decides which to use:

File extension Module system
.cjs Always CommonJS
.mjs Always ES Module
.js Depends on nearest package.json "type" field
// package.json
{
  "type": "module"   // → all .js files are ESM
}
{
  "type": "commonjs" // → default; all .js files are CJS
}

When writing a library that needs to support both, generate two outputs:

{
  "main":    "./dist/index.cjs",
  "module":  "./dist/index.mjs",
  "exports": {
    ".": {
      "import":  "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Interop: using CJS from ESM and vice versa

CJS from ESM — default import works:

// ESM file
import lodash from 'lodash';                    // default import of module.exports
const { cloneDeep } = lodash;

// Or named-style (bundlers usually handle this)
import { cloneDeep } from 'lodash';

ESM from CJS — requires dynamic import because require() is synchronous but ESM evaluation is asynchronous:

// CJS file
const run = async () => {
  const { myFn } = await import('./esm-module.mjs');
  myFn();
};
run();

Or use createRequire from module:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacyLib = require('./legacy-commonjs');

import.meta — ESM metadata

In ES Modules, import.meta replaces Node's __filename and __dirname:

// ESM equivalent of __filename / __dirname
import { fileURLToPath } from 'url';
import { dirname, join }  from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);
const configPath = join(__dirname, 'config.json');

import.meta.url is also available in the browser:

// In a browser ES Module
console.log(import.meta.url); // https://example.com/js/app.js

Browser ES Modules

Use type="module" on <script> tags:

<script type="module" src="./app.js"></script>

<script type="module">
  import { add } from './math.js';
  console.log(add(1, 2));
</script>

Key browser behaviours:

  • Deferred by default — runs after HTML is parsed (no need for defer)
  • CORS required — imports must come from the same origin or include CORS headers (can't use file:// without a server)
  • Cached — each URL is fetched and evaluated only once per page
  • Strict mode — modules always run in strict mode

Use an import map to alias bare specifiers in the browser:

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js"
  }
}
</script>

<script type="module">
  import { cloneDeep } from 'lodash'; // works!
</script>

Tree shaking

Bundlers (Webpack, Rollup, Vite) can eliminate unused exports from ES Modules — a process called tree shaking.

// utils.js — 3 exports
export function a() { return 'a'; }
export function b() { return 'b'; }
export function c() { return 'c'; }
// app.js — only imports `a`
import { a } from './utils.js';

After bundling, b and c are removed from the output because they're never imported. Tree shaking works on ES Modules because their imports are static — the bundler can analyse them without running the code.

CommonJS require() is dynamic, so bundlers can't reliably tree-shake CJS. This is one of the main reasons libraries are migrating to ESM.


Module patterns quick reference

Pattern How
Named exports export const x = ...
Default export export default function() {}
Rename on export export { fn as myFn }
Barrel re-export export { x } from './x.js'
Re-export default export { default as Foo } from './foo.js'
Dynamic import const m = await import('./m.js')
Namespace import import * as Utils from './utils.js'
Side-effect only import './polyfill.js'
CJS export module.exports = { ... }
CJS import const x = require('./x')

7 common mistakes

Mistake Why it breaks Fix
Missing .js extension in ESM Node ESM resolves exactly as written Always add .js (even for TypeScript source — use .js as the emit extension)
import inside if Syntax error — static import must be top-level Use dynamic import() inside conditionals
require() in .mjs Not defined in ES Modules Use import or createRequire
__dirname in ESM Not defined Use fileURLToPath(import.meta.url) + dirname()
Circular imports Can lead to undefined values Restructure: extract shared code to a third module
Mixing export default and module.exports module.exports overwrites everything in CJS Choose one system per file
Named import of a CJS default import { named } from 'cjs-pkg' may be undefined Import the default and destructure: import cjs from 'pkg'; const { named } = cjs;

6 FAQ

Q: Should I use ESM or CommonJS for a new Node.js project?
A: ESM. It's the standard, supported natively in Node 12+ and all modern browsers. Use "type": "module" in package.json. CommonJS is still fine for simple scripts or when your dependencies are CJS-only.

Q: Can I use await at the top level of an ES Module?
A: Yes — top-level await is supported in ES Modules (Node 14.8+ and all modern browsers). In CommonJS you must wrap code in an async function.

// ESM — this works
const data = await fetch('https://api.example.com/data').then(r => r.json());

Q: Why does my bundler bundle things that I didn't import?
A: If a module has side effects (it runs code when required/imported, not just defines exports), bundlers include it even if you don't use its exports. Mark side-effect-free packages in package.json: "sideEffects": false.

Q: What's the difference between import x from and import * as x from?
A: import x from gets the default export. import * as x from creates a namespace object with all named exports (and x.default for the default export). Don't use namespace imports for tree-shaking-sensitive code.

Q: How do I use an ESM package in a CommonJS project?
A: Use dynamic import() in an async context, since require() can't load ES Modules synchronously. Alternatively, configure your bundler (Webpack/Rollup) to handle the interop.

Q: What is "exports" in package.json?
A: The "exports" field (Node 12+) lets a package define multiple entry points and conditional exports (import vs require). It also blocks access to internal files not explicitly listed — a security and encapsulation improvement over the older "main" field alone.

{
  "exports": {
    ".":          { "import": "./esm/index.js", "require": "./cjs/index.js" },
    "./utils":    { "import": "./esm/utils.js",  "require": "./cjs/utils.js" }
  }
}

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools