JavaScript ES Modules Explained: import, export, and the Modern Module System

Link copied
JavaScript ES Modules Explained: import, export, and the Modern Module System

JavaScript ES Modules Explained: import, export, and the Modern Module System

JS Tutorial Module 6: Modern JS Lesson 6.1

ECMAScript Modules — ESM, or just "ES modules" — are JavaScript's standard module system. They replaced the older CommonJS (require/module.exports) and AMD systems with a syntax baked into the language. In 2026, every modern runtime (browsers, Node 22+, Deno, Bun, edge) speaks ESM natively, and tooling defaults to it.

This lesson covers the import/export syntax, named vs default exports, dynamic imports, the differences from CommonJS, top-level await in modules, import maps, and the practical conventions that have settled into the ecosystem.

The basics: export and import #

A module is a file that exports values and optionally imports from other modules.

// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// app.js
import { add, multiply, PI } from './math.js';
console.log(add(PI, multiply(2, 3))); // 9.14159

Three pieces:

  1. export — marks a value as available for other modules to import. Multiple per file.
  2. import { name } from 'path' — pulls named exports into the current scope.
  3. The file path — relative (./math.js) or bare specifier (react, @scope/lib) resolved by the host.

Named exports #

The most common form. Any number per file:

// Multiple at declaration
export const x = 1;
export const y = 2;
export function fn() {}

// All in one export block
const a = 1, b = 2;
function c() {}
export { a, b, c };

// With renaming
export { a as alpha, b as beta };

Imported by exact name:

import { x, y, fn } from './lib.js';
import { a as alpha } from './lib.js'; // rename on import
import * as lib from './lib.js';        // namespace import — lib.x, lib.y, ...

Default exports #

One per file. The single "main" export:

// User.js
export default class User {
  constructor(name) { this.name = name; }
}
// app.js
import User from './User.js'; // no braces — name is yours to choose
import Person from './User.js'; // works too — local name is free

Mixing both works:

// api.js
export default function createClient() {}
export const VERSION = '1.0.0';
import createClient, { VERSION } from './api.js';

The ecosystem split: named exports are increasingly preferred in modern libraries because they're refactor-friendly (renaming in one place reveals all users), tree-shake-friendly, and IDE-friendly (auto-import picks the right name). Single-default exports are common for classes, components, and "the thing" each file is about.

Re-exporting #

For barrel files (index.js that re-exports from sibling files):

export { add, multiply } from './math.js';
export { format } from './string.js';
export * from './utils.js';            // re-export everything
export { default as User } from './User.js'; // re-export a default as named

Useful for packages — one entry point gathers all the publicly-exported names.

Caveat: barrel files can hurt tree-shaking if the bundler can't statically prove which exports are actually used. Modern bundlers (esbuild, Vite, Rollup) handle this well, but for hot-path performance, importing directly from the file is sometimes faster.

Dynamic imports #

The import keyword can also be used as a function — returns a Promise:

const { default: Modal } = await import('./Modal.js');
Modal.show();

Uses:

  • Code splitting — load a route's code only when the user navigates to it.
  • Conditional features — load a feature module only when a flag is enabled.
  • Heavy libraries — defer loading until the user clicks the button that needs it.

Bundlers (Vite, Webpack, esbuild) split dynamic imports into separate chunks automatically.

Top-level await #

In ESM (not CommonJS), await works at the module's top level:

// config.js
const raw = await fetch('/config.json');
export const config = await raw.json();

Modules that import ./config.js wait for these awaits to settle before their own code runs. Useful for one-shot setup (database connections, config loading, decryption) without wrapping everything in a main() function.

In ES modules, imports are always sync-shaped at the call site — you don't await an import. The module system handles the wait internally.

ESM vs CommonJS #

In Node (and tools that emulate it), CommonJS uses require/module.exports:

// CommonJS
const fs = require('node:fs');
function read(p) { return fs.readFileSync(p, 'utf8'); }
module.exports = { read };

The key differences from ESM:

Aspect CommonJS ESM
Syntax require(), module.exports import, export
Loading Synchronous, runtime Static, parse-time
Top-level await No Yes
Tree-shaking Hard Easy
Default extension .js (in CJS projects) .js / .mjs (in ESM projects)
__dirname / __filename Available Use import.meta.url
Circular imports Loose (may see partial values) Strict (bindings are live)

For a Node project, pick one via package.json:

{ "type": "module" } // makes .js files ESM by default

New projects default to ESM. Existing CJS projects gradually migrate.

ESM in the browser #

Load a module with <script type="module">:

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

Key things browser modules do for you:

  • Always deferred. Wait for HTML parse before running. No defer attribute needed.
  • Strict mode by default.
  • this is undefined, not the global object.
  • CORS-checked — modules loaded cross-origin need permissive CORS headers.
  • Cached by URL — every import of the same URL gets the same module instance.

For production, bundlers produce optimized ESM bundles that browsers and runtimes can load.

Import paths and bare specifiers #

Four styles:

import x from './local.js';           // relative
import x from '/abs.js';                // absolute, from server root
import x from 'https://cdn.x/lib.js'; // URL (Deno, browser, sometimes Bun)
import x from 'lodash';                  // bare — resolved by host (node_modules, import map)

Import maps #

Bare specifiers in browsers need an import map:

<script type="importmap">
{
  "imports": {
    "lodash": "https://esm.sh/lodash",
    "@/": "/src/"
  }
}
</script>

Then import _ from 'lodash' works in the browser. Most production sites still ship a bundled build instead, but import maps make zero-build development setups viable.

Live bindings #

ESM imports are live bindings, not values. The imported name reflects the current value of the export — even if it changes:

// counter.js
export let count = 0;
export function increment() { count++; }
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — sees the update

This is different from CommonJS, where you'd get a snapshot of the value at require time.

In practice, most exports are functions or const objects — the live-binding distinction rarely surfaces.

import.meta #

A built-in object with module-specific metadata:

import.meta.url;        // the URL of this module (e.g. 'file:///path/to/app.js')
import.meta.resolve(s); // resolve a specifier relative to this module

Replaces __dirname and __filename from CommonJS. To get a directory path in Node:

import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

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

A few conventions #

  • Use named exports by default. Default exports for component files, classes that are the entire purpose of the file.
  • File extensions in import paths — always include .js (or .mjs/.ts) in modern Node ESM. Browsers require them. Bundlers can sometimes infer them.
  • One responsibility per file. Modules should be small and focused.
  • Avoid side effects on import. A file with console.log('loaded') at the top makes mocking and testing harder.
  • Use barrel files sparingly. Useful for public API, but verbose and tree-shake-unfriendly when overused.

A summary #

  • ESM is the standard. Every modern runtime supports it natively.
  • export marks a value as importable. import brings it in.
  • Named and default exports can coexist. Prefer named for libraries.
  • Dynamic import() returns a Promise — for code splitting and lazy loading.
  • Top-level await works in ESM, not CommonJS.
  • Bindings are live, not snapshots.
  • import.meta replaces CommonJS metadata (__dirname, etc.).
  • Browsers, Node 22+, Deno, Bun, and edge runtimes all speak ESM.

That's the full module system in one page. Everything else is bundler configuration.

What's next #

That completes Module 6 and the entire Modern JavaScript Tutorial path. You now have:

  • Module 1: Foundations — types, variables, operators, conditionals, loops, basic functions
  • Module 2: Functions Deep Dive — closures, scope, this, higher-order, pure functions
  • Module 3: Objects, Arrays & Iteration — including spread, destructuring, descriptors
  • Module 4: Classes & OOP — including private fields and composition
  • Module 5: Asynchronous JavaScript — callbacks, Promises, async/await, the event loop
  • Module 6: Modern JS — modules, ES2024 features, coercion, memory
  • Module 7: Browser & DOM — selection, events, forms, fetch, storage

From here, the natural next directions are framework-specific paths (Angular, React, Vue), specialized tracks like RxJS or TypeScript, or topic deep dives. Each builds on the foundation you just laid.

Try it yourself #

Live bindings are the easiest ESM feature to feel. Predict the output of these two files:

YouTwo files:
// counter.js
export let n = 0;
export const bump = () => n++;

// main.js
import { n, bump } from './counter.js';
console.log(n);
bump();
console.log(n);

What does main.js print?
Claude · used js_sandboxOutput:
0 (initial value)
1 (after bump — the imported n reflects the live value, not a snapshot)

You can’t reassign n from main.js (n = 2 would throw — imports are read-only). But you CAN see the exporter’s mutation. That’s the difference between ESM’s live bindings and CommonJS’s value snapshots.

This property is what lets a module export a value that callers see updates to without explicit notification — useful for module-level state, less footgun than it sounds when most exports are functions and consts anyway.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

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