JavaScript Modules: A Complete Reference Guide

Master ES6 import/export syntax, compare CommonJS and ES modules, and learn best practices for organizing modern JavaScript code.

Why JavaScript Modules Matter

Before ES6, JavaScript developers struggled with code organization. Scripts loaded sequentially with <script> tags, leading to global namespace pollution, naming conflicts, and tangled dependencies. The module pattern and IIFEs provided partial solutions, but a standardized approach was needed.

ES6 introduced native ECMAScript Modules, revolutionizing how we organize and share code. Today, all modern browsers support ES modules natively, enabling better tooling, tree shaking, and cleaner code organization. For teams building complex web applications, mastering module patterns is essential for maintainable, scalable codebases. Modern JavaScript development benefits significantly from adopting these standardized patterns early in the project lifecycle.

The Evolution of JavaScript Modules

Timeline of Module Systems

  • 1995-2005: Sequential <script> tags with global scope
  • 2005-2009: Namespace pattern and IIFE for encapsulation
  • 2009: CommonJS emerges for server-side JavaScript (Node.js)
  • 2010-2015: AMD and RequireJS for browser asynchronous loading
  • 2015: ES6 introduces official ECMAScript Modules standard
  • 2020+: Universal browser support for native ES modules

Problems Modules Solve

  • Code organization: Break large applications into manageable pieces
  • Namespace isolation: Prevent variable and function name collisions
  • Dependency management: Explicitly declare what code depends on
  • Reusability: Share code across multiple projects easily
  • Testing: Test individual modules in isolation
  • Caching: Modules are cached after first load

Core Module Syntax

Exporting Module Features

Named exports can be declared inline or at the end of the module:

// Inline named exports
export const name = "square";
export function draw(ctx, length, x, y, color) {
 ctx.fillStyle = color;
 ctx.fillRect(x, y, length, length);
 return { length, x, y, color };
}

// Export at the end (recommended for clarity)
const API_KEY = "secret";
const MAX_USERS = 1000;
function configure() { /* ... */ }

export { API_KEY, MAX_USERS, configure };

Re-exporting from other modules:

// Re-export everything
export * from "./utils.js";

// Re-export with renaming
export { formatDate } from "./date-utils.js";

Importing Features

Basic named imports:

import { name, draw, reportArea } from "./shapes.js";
import { name as shapeName, draw as drawShape } from "./shapes.js";

Default imports:

import myFunction from "./utils.js";
import MyClass from "./components/Button.js";

Namespace imports (creates an object with all exports):

import * as Shapes from "./shapes.js";
Shapes.draw();
Shapes.reportArea();

Combined imports:

import React, { useState, useEffect } from "react";

Side-effect imports (execute module but don't import values):

import "./analytics.js"; // Runs initialization code

Default vs Named Exports

Default exports:

  • One per module (by convention)
  • Represents the "main" thing the module provides
  • Can be imported with any name
  • Example: export default function App() {}

Named exports:

  • Multiple per module
  • Explicitly named, self-documenting
  • Must be imported with matching names (or aliased)
  • Example: export const API_URL = "https://api.example.com"

Best practices:

  • Use default exports for the primary function/class
  • Use named exports for utilities and helpers
  • Avoid mixing both unnecessarily

Module Resolution and Paths

How Browser Resolves Modules

// Relative paths (recommended)
import { utils } from "./utils.js";
import { helpers } from "../lib/helpers.js";

// Absolute paths
import { config } from "/app/config.js";

// Bare specifiers (require build tool or import maps)
import React from "react";

Import Maps for Bare Specifiers

<script type="importmap">
{
 "imports": {
 "react": "https://esm.sh/[email protected]",
 "react-dom": "https://esm.sh/[email protected]",
 "lodash": "https://esm.sh/[email protected]",
 "@utils/": "./src/utils/"
 }
}
</script>

<script type="module">
import React from "react";
import { debounce } from "lodash";
import { formatDate } from "@utils/date.js";
</script>

Benefits:

  • Use bare specifiers without bundlers
  • CDN integration for dependencies
  • Path aliasing for cleaner imports

Browser Integration

Using Modules in HTML

<!-- Regular script (no module features) -->
<script src="app.js"></script>

<!-- Module script (ES6 module) -->
<script type="module" src="main.js"></script>

<!-- Inline module -->
<script type="module">
 import { greet } from "./greetings.js";
 console.log(greet("World"));
</script>

Key Differences from Regular Scripts

FeatureRegular ScriptModule Script
Strict modeNoYes (always)
Top-level scopeGlobalPrivate
ExecutionEvery importOnce
LoadingBlockingDeferred
HTML commentsAllowedNot allowed

Defer and Async Behavior

Module scripts are automatically deferred:

  • HTML parsing continues while modules load
  • Modules execute in order after HTML is parsed
  • Better page load performance
<!-- Both scripts load in parallel, execute in order -->
<script type="module" src="a.js" defer></script>
<script type="module" src="b.js" defer></script>

CommonJS vs ES Modules

Syntax Comparison

FeatureCommonJSES Modules
Importrequire()import
Exportmodule.exportsexport
SynchronousYesNo (async)
Static analysisLimitedFull support
Tree shakingNoYes
HoistingNoYes (imports)
Browser nativeNo (needs bundler)Yes

CommonJS Example

// utils.js
const API_URL = "https://api.example.com";
function formatData(data) {
 return data.trim();
}
module.exports = { API_URL, formatData };

// main.js
const { API_URL, formatData } = require("./utils.js");

ES Modules Example

// utils.js
export const API_URL = "https://api.example.com";
export function formatData(data) {
 return data.trim();
}

// main.js
import { API_URL, formatData } from "./utils.js";

Key Behavioral Differences

  1. Loading: CommonJS loads synchronously; ESM loads asynchronously
  2. Binding: ESM imports are "live bindings" to exported values
  3. Hoisting: ESM imports are hoisted; CommonJS requires are not
  4. Top-level await: ESM supports await at module top level

Live binding example:

// counter.js
export let count = 0;
export function increment() {
 count++;
}

// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 (reflects change)

Node.js Interoperability

Node.js supports both CommonJS and ESM with these rules:

  • Files ending in .mjs are treated as ESM
  • Files ending in .cjs are treated as CommonJS
  • Nearest package.json with "type": "module" makes .js files ESM

Mixed import patterns:

// In an ESM file, import CommonJS
import fs from "fs"; // fs is CommonJS, but works with ESM

// In a CommonJS file, dynamically import ESM
const esmModule = await import("./module.mjs");

Advanced Module Patterns

Dynamic Imports

Static vs dynamic imports:

// Static import (always runs at module load time)
import { utils } from "./utils.js";

// Dynamic import (runs when you call it)
button.addEventListener("click", async () => {
 const { heavyFunction } = await import("./heavy.js");
 heavyFunction();
});

Use cases:

  • Code splitting for performance
  • Conditional loading based on user interaction
  • Lazy loading routes in SPAs
  • Loading polyfills conditionally

Re-exporting Patterns (Barrel Pattern)

// src/components/index.js
export { Button } from "./Button.js";
export { Modal } from "./Modal.js";
export { Input } from "./Input.js";
export { Card } from "./Card.js";

// Usage
import { Button, Modal, Card } from "./components/index.js";

Circular Dependencies

ESM handles circular dependencies gracefully:

// a.js
import { b } from "./b.js";
export const nameA = "Module A";
export function getB() {
 return b;
}

// b.js
import { nameA } from "./a.js";
export const nameB = "Module B";

Best Practices

Recommended File Structure

src/
├── main.js # Entry point
├── components/
│ ├── index.js # Barrel export
│ ├── Button.js
│ └── Modal.js
├── utils/
│ ├── index.js # Barrel export
│ ├── date.js
│ └── string.js
└── hooks/
 └── index.js # Barrel export

Naming Conventions

  • Use lowercase with kebab-case for file names: my-component.js
  • Use PascalCase for component files: Button.js, UserCard.js
  • Use camelCase for utility files: date-utils.js, formatters.js
  • Use consistent naming in exports matching file names

Avoiding Common Pitfalls

  1. Path errors: Ensure relative paths start with ./ or ../
  2. File extensions: Include .js extension or configure resolution
  3. Missing exports: Check that imported names match exported names
  4. Cyclic imports: Design to minimize circular dependencies
  5. Default vs named: Know which you're importing
  6. Live bindings: Don't cache imported values expecting them to be static

Performance Optimization

  • Use dynamic imports for code splitting
  • Prefer tree-shakable named exports over default exports
  • Configure bundlers for optimal chunk sizes
  • Use import maps for CDN optimization
  • Consider HTTP/2 multiplexing for many small modules. These performance optimization techniques are essential for building fast, scalable applications.

Troubleshooting Common Errors

ErrorCauseSolution
Cannot use import statement outside a moduleFile extension not .mjs or missing type:moduleAdd "type": "module" to package.json
Module not foundWrong path or missing extensionCheck relative path, add .js extension
Export 'X' was not foundName mismatch in importVerify export name matches
CORS errorCross-origin import without proper headersConfigure CORS on server
Top-level await is not availableNode.js version too oldUpdate Node.js or use dynamic import

Conclusion

ES Modules provide a standardized, native way to organize JavaScript code. With universal browser support and excellent tooling, they're the clear choice for modern web development. Start using modules in your projects to improve code organization, enable better tooling, and prepare for the future of JavaScript.

Key takeaways:

  • Use export and import for all new projects
  • Prefer named exports for utilities, default for main exports
  • Organize code with barrel exports (index.js files)
  • Use dynamic imports for code splitting
  • Configure package.json type field for Node.js projects

If you're looking to refactor your existing codebase to modern module patterns or need help with web application development, our team can help you modernize your JavaScript architecture for better maintainability and performance. Implementing proper web development practices from the start prevents technical debt and improves long-term project success.

Frequently Asked Questions

Do browsers support ES modules natively?

Yes, all modern browsers (Chrome, Firefox, Safari, Edge) support ES modules natively without any build tools. You can use `<script type="module">` directly in HTML.

Should I use named or default exports?

Use default exports for the primary export of a module and named exports for utilities and helpers. Named exports generally work better with tree shaking and IDE autocompletion.

Can I use CommonJS modules with ES modules?

Yes, Node.js supports interoperability. You can import CommonJS modules in ESM, but you cannot import ESM in CommonJS without dynamic import().

What is the difference between import and require()?

import is asynchronous and statically analyzable (enabling tree shaking), while require() is synchronous and limited in static analysis. ES modules also have live bindings to exports.

How do I configure my project for ES modules?

Add `"type": "module"` to your package.json, or name your files with `.mjs` extension. All .js files will then be treated as ES modules.

Ready to Modernize Your JavaScript Development?

Our team of expert JavaScript developers can help you refactor your codebase to use modern module patterns and best practices.