JavaScript ES Modules import export tutorial 2026

JavaScript ES Modules: import, export, and Dynamic Loading

ES modules are the official JavaScript module system standardized in ECMAScript 2015 (ES6). Unlike CommonJS require(), the import and export syntax is built into the language itself, works natively in browsers, and enables static analysis for tree-shaking and optimization. Every modern JavaScript project, from React apps to Node.js servers, now uses ES modules as the default. This tutorial covers everything from basic import/export syntax to advanced patterns like dynamic imports, top-level await, and interoperability with CommonJS.

What Are ES Modules?

ES Modules (ESM) provide a declarative syntax for sharing code between JavaScript files. Unlike CommonJS require(), ESM imports are resolved at parse time before code executes, making them statically analyzable. This means bundlers can determine exactly which exports are used and eliminate dead code through tree-shaking.

// math.mjs
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// app.mjs
import { add, multiply } from './math.mjs';
console.log(add(2, 3));       // 5
console.log(multiply(4, 5));  // 20

Key characteristics of ES Modules:

  • Static structure — imports and exports must be at the top level (not inside conditionals)
  • Asynchronous loading — modules load without blocking
  • Strict mode by default — no need for "use strict"
  • Live bindings — imports are live references, not copies

Named Exports and Imports

Named exports let you export multiple values from a module. Each exported value must be imported by its exact name (or renamed with as).

Exporting

// Inline exports
export const PI = 3.14159;
export function circleArea(r) { return PI * r * r; }
export class Circle {
  constructor(r) { this.radius = r; }
  area() { return PI * this.radius * this.radius; }
}

// Or export at the bottom (equivalent)
const PI = 3.14159;
function circleArea(r) { return PI * r * r; }
class Circle {
  constructor(r) { this.radius = r; }
}
export { PI, circleArea, Circle };

Importing

// Import specific names
import { PI, circleArea } from './geometry.mjs';

// Rename on import
import { circleArea as calcArea } from './geometry.mjs';

// Import everything as a namespace object
import * as Geometry from './geometry.mjs';
console.log(Geometry.PI);            // 3.14159
console.log(Geometry.circleArea(5)); // 78.539...

Named exports work excellently with destructuring patterns, making your imports clean and readable.

Default Exports and Imports

Each module can have one default export. Default exports are convenient for modules that export a single primary value like a class, function, or configuration object.

// logger.mjs
export default class Logger {
  constructor(prefix) {
    this.prefix = prefix;
  }

  log(msg) {
    console.log(`[${this.prefix}] ${msg}`);
  }

  error(msg) {
    console.error(`[${this.prefix}] ERROR: ${msg}`);
  }
}

// app.mjs — import with any name
import Logger from './logger.mjs';
import MyLogger from './logger.mjs'; // Also valid, same thing

Mixing Default and Named Exports

// api.mjs
export default function fetchData(url) {
  return fetch(url).then(r => r.json());
}
export const BASE_URL = 'https://api.example.com';
export const TIMEOUT = 5000;

// consumer.mjs
import fetchData, { BASE_URL, TIMEOUT } from './api.mjs';

Re-exporting and Barrel Files

Re-exports let you aggregate multiple modules into a single entry point, commonly called a “barrel file.” This is essential for organizing large projects.

// utils/index.mjs (barrel file)
export { default as Logger } from './logger.mjs';
export { fetchData, BASE_URL } from './api.mjs';
export { formatDate, parseDate } from './dates.mjs';
export * from './math.mjs'; // Re-export all named exports

// Now consumers import from one place:
import { Logger, fetchData, formatDate, add } from './utils/index.mjs';

Renaming on Re-export

export { fetchData as getData } from './api.mjs';
export { default as default } from './main-component.mjs'; // Forward default

Dynamic import() for Code Splitting

While static import must be at the top level, import() is a function that returns a Promise and can be used anywhere. This enables code splitting and lazy loading.

// Conditional loading
async function loadChart(type) {
  if (type === 'bar') {
    const { BarChart } = await import('./charts/bar.mjs');
    return new BarChart();
  } else {
    const { LineChart } = await import('./charts/line.mjs');
    return new LineChart();
  }
}

// Route-based code splitting
const routes = {
  '/dashboard': () => import('./pages/dashboard.mjs'),
  '/settings': () => import('./pages/settings.mjs'),
  '/profile': () => import('./pages/profile.mjs'),
};

async function navigate(path) {
  const loader = routes[path];
  if (loader) {
    const module = await loader();
    module.default.render();
  }
}

Dynamic import() works with async/await for clean asynchronous loading patterns. Bundlers like Webpack and Rollup automatically split dynamic imports into separate chunks.

ES Modules in Node.js

Node.js has supported ES Modules since version 12 (unflagged since v13.2). There are two ways to enable ESM in Node.js:

Method 1: .mjs Extension

// server.mjs — automatically treated as ESM
import express from 'express';
import { readFile } from 'node:fs/promises';

const app = express();
app.listen(3000);

Method 2: package.json “type”: “module”

// package.json
{
  "name": "my-app",
  "type": "module"  // All .js files are now ESM
}

// server.js — treated as ESM because of package.json
import express from 'express';

Importing CommonJS from ESM

// You CAN import CommonJS modules from ESM
import lodash from 'lodash'; // CommonJS default export
import { readFileSync } from 'node:fs'; // Node built-in

// But CJS modules cannot use static import for ESM
// They must use dynamic import():
// const { something } = await import('./esm-module.mjs');

No __filename or __dirname in ESM

// These don't exist in ESM. Use import.meta instead:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

ES Modules in the Browser

Modern browsers natively support ES Modules via the <script type="module"> tag:

// In HTML:
// <script type="module" src="./app.mjs"></script>

// Or inline:
// <script type="module">
//   import { greet } from './utils.mjs';
//   greet('World');
// </script>

Key browser behaviors:

  • Module scripts are deferred by default (execute after HTML parsing)
  • Module scripts execute in strict mode
  • Module scripts are fetched with CORS
  • Each module is only executed once regardless of how many times it’s imported

For production, you should still bundle modules with tools like Vite or Rollup to minimize HTTP requests.

Top-Level Await

ES Modules support await at the top level (no wrapping async function needed). This is impossible in CommonJS.

// config.mjs
const response = await fetch('https://api.example.com/config');
export const config = await response.json();

// database.mjs
import { config } from './config.mjs';
const db = await connectToDatabase(config.dbUrl);
export default db;

// app.mjs
import db from './database.mjs';
// db is fully connected and ready to use here
const users = await db.query('SELECT * FROM users');

Top-level await makes initialization patterns much cleaner. The module won’t be “ready” until all awaited values resolve, and any module that imports it will wait automatically. Learn more about async/await fundamentals.

ESM vs CommonJS: Key Differences

Live Bindings vs Copies

This is the most important conceptual difference. CommonJS exports copies of values, while ESM exports live bindings:

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

// app.mjs
import { count, increment } from './counter.mjs';
console.log(count);   // 0
increment();
console.log(count);   // 1 — live binding updated!
// counter.cjs (CommonJS)
let count = 0;
module.exports = { count, increment() { count++; } };

// app.cjs
const { count, increment } = require('./counter.cjs');
console.log(count);   // 0
increment();
console.log(count);   // 0 — still 0! It's a copy.

Parsing vs Execution

ESM has a two-phase approach: the module structure (imports/exports) is analyzed at parse time, then code executes. CommonJS resolves everything at runtime. This is why ESM import statements must be at the top level and cannot be inside if blocks.

Module Resolution and Import Maps

Import maps let you control how bare specifiers resolve in the browser without a bundler:

// In HTML:
// <script type="importmap">
// {
//   "imports": {
//     "lodash": "https://cdn.skypack.dev/lodash-es",
//     "react": "https://cdn.skypack.dev/react",
//     "@/utils/": "./src/utils/"
//   }
// }
// </script>

// Now you can use bare specifiers in the browser:
import _ from 'lodash';
import { formatDate } from '@/utils/dates.mjs';

In Node.js, the resolution algorithm for ESM is different from CommonJS. ESM requires full file extensions in relative imports (no automatic .js resolution) and does not support require.resolve().

Common Mistakes and Pitfalls

1. Missing File Extensions in Node.js ESM

// WRONG in Node.js ESM
import { helper } from './utils'; // Error: Cannot find module

// CORRECT
import { helper } from './utils.mjs';
import { helper } from './utils.js';

2. Importing CommonJS Named Exports

// If lodash uses CommonJS, this may not work:
import { map, filter } from 'lodash'; // Might fail

// Use default import instead:
import _ from 'lodash';
const result = _.map([1, 2, 3], n => n * 2);

3. Circular Imports Behave Differently

ESM handles circular dependencies differently from CommonJS. Because of live bindings, you might get undefined for a variable that hasn’t been initialized yet, rather than a partially-loaded object.

4. Cannot require() an ESM Module

// In a CommonJS file:
const esm = require('./module.mjs'); // ERROR!

// Must use dynamic import:
const esm = await import('./module.mjs'); // OK

5. this Is undefined in ESM

// In CommonJS: this === module.exports (at top level)
// In ESM: this === undefined
console.log(this); // undefined in ESM

Understanding how the this keyword varies across contexts is essential for avoiding these issues.

Summary and Key Takeaways

  • ES Modules use import/export syntax and are the standard for modern JavaScript
  • Named exports allow multiple exports; default exports provide a single primary value
  • Dynamic import() returns a Promise and enables code splitting and lazy loading
  • ESM provides live bindings (references, not copies) unlike CommonJS
  • Enable ESM in Node.js via .mjs extension or "type": "module" in package.json
  • Top-level await works in ESM but not CommonJS
  • Browsers support ESM natively via <script type="module">
  • Always use full file extensions in Node.js ESM imports
  • Import maps enable bare specifiers in browsers without bundlers
  • The ecosystem is migrating to ESM; learn both systems to work with any codebase

With both CommonJS and ES Modules mastered, you have a complete understanding of JavaScript’s module systems. Next, dive into Classes and Prototypes to learn how object-oriented programming works in JavaScript.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *