what is esm
ecmascript modules
javascript modules
commonjs vs esm
node.js modules

What is ESM? A Guide to JavaScript Modules in 2026

What is ESM? A Guide to JavaScript Modules in 2026

TL;DR: ECMAScript Modules (ESM) are JavaScript’s official standard module system, using import and export to share code across files, while CommonJS is the older Node.js module system built around require() and module.exports. If you’re building modern JavaScript in the browser or Node.js, ESM is the model to learn first because it aligns better with current tooling, cleaner architecture, and long-term maintenance.

You’re probably in one of two places right now. Either you opened a project and saw "type": "module", import, and export everywhere, or you tried to install a package and ran straight into module errors that made you wonder why JavaScript still seems to have two ways to do the same thing.

That confusion is normal. “What is ESM” sounds like a simple question, but for real teams it affects a lot more than syntax. It changes how you structure files, how your build tool optimizes bundles, how your test setup behaves, and how painful future maintenance becomes.

The short version is this. ESM isn’t just a nicer way to split code into files. It’s the module system the JavaScript ecosystem is standardizing around, and that has strategic consequences for every application you build.

The Fundamentals of ECMAScript Modules

Think about a JavaScript app like a box of LEGO pieces. If every brick is dumped into one giant pile with no labels, you can still build something, but it gets messy fast. Modules solve that problem by letting you group related logic into small, named pieces with a clear public surface.

That’s what ECMAScript Modules, or ESM, do. They let one file define what it wants to share, and let another file explicitly ask for only what it needs. That sounds basic, but it creates the foundation for code that’s easier to reason about, test, and replace later.

A wooden chest containing different shaped puzzle blocks labeled math.js, utils.js, and logger.js representing modular code components.

How exports define a module’s public API

A module should expose only the pieces other files need. In ESM, you do that with export.

Here’s a simple file called math.js:

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

This file exports two named functions. Any other module can import one or both.

import { add, subtract } from './math.js';

console.log(add(2, 3));
console.log(subtract(5, 1));

That syntax matters because it makes dependencies explicit. When you read the file, you can see what comes from elsewhere. You don’t have to guess what got attached to some object earlier in the runtime.

Default exports versus named exports

You’ll also see default exports. A default export is useful when a module has one main thing to expose.

export default function formatCurrency(value) {
  return `$${value.toFixed(2)}`;
}

You import it without curly braces:

import formatCurrency from './formatCurrency.js';

Named exports are usually better when a module exposes several related utilities. Default exports are handy when the module has a single clear responsibility.

A mixed example looks like this:

export const LOG_LEVELS = ['info', 'warn', 'error'];

export function log(message) {
  console.log(message);
}

export default function createLogger(prefix) {
  return function (message) {
    console.log(`[${prefix}] ${message}`);
  };
}

And the import side:

import createLogger, { log, LOG_LEVELS } from './logger.js';

Practical rule: Prefer named exports for shared utility modules. They make refactors safer because your imports stay explicit.

Why this matters beyond syntax

The biggest beginner mistake is treating modules as file separators and nothing more. They’re more than that. They’re boundaries.

Good module design usually means:

  • One reason to change: A file should own one clear concern, like date formatting or API requests.
  • A small public surface: Export only what other files need.
  • Low coupling: Don’t make every module reach into every other module.
  • Predictable imports: Keep paths and naming consistent so the graph stays readable.

If your app still feels fuzzy, start by splitting it into domains. A cart module. A pricing module. A logger module. An auth module. The syntax is the easy part. The architecture is where teams win or lose.

If you’re testing this locally and want a quick refresher on executing JavaScript in a server environment, this guide on how to run JavaScript in Node is a useful companion.

ESM vs CommonJS A Modern JavaScript Showdown

Most of the confusion around ESM comes from history. JavaScript didn’t start with a native module system. Browsers had script tags. Early Node.js popularized CommonJS, which gave developers require() and module.exports.

That worked well for server-side JavaScript for a long time. But it also meant the ecosystem grew around a module format that wasn’t the language standard. ESM came later and solved that mismatch by giving JavaScript one official module model across environments.

The syntax difference is only the surface

CommonJS looks like this:

const fs = require('fs');
const utils = require('./utils');

module.exports = function parseFile(path) {
  return utils.parse(fs.readFileSync(path, 'utf8'));
};

ESM looks like this:

import fs from 'fs';
import { parse } from './utils.js';

export default function parseFile(path) {
  return parse(fs.readFileSync(path, 'utf8'));
}

That’s the visible part. The deeper difference is how each system thinks about loading, dependency graphs, and execution.

ESM vs. CommonJS at a Glance

Feature ECMAScript Modules (ESM) CommonJS (CJS)
Standard status Official JavaScript module standard Older Node.js module pattern
Import syntax import require()
Export syntax export / export default module.exports / exports
Analysis by tools Static and easier to analyze before execution More dynamic and harder to analyze reliably
Browser support Native in modern browsers with module scripts Not native in browsers
Loading model Designed around modern module loading semantics Synchronous loading style in Node.js
Tree-shaking friendliness Strong fit for modern bundlers Usually weaker because imports can be dynamic
Async features Works naturally with dynamic import() and top-level await Doesn’t offer the same native model
Legacy package compatibility May require interop handling with older packages Works naturally with older Node.js packages

Why static structure changes build strategy

Here’s the key architectural point. In ESM, imports and exports are declared at the top level in a predictable way. Build tools can inspect that graph before they run your code.

That one property provides several practical benefits:

  • Cleaner bundling: Tools like Rollup and Vite can see what’s imported and what isn’t.
  • Better dead-code removal: If no file imports a function, a bundler can often leave it out.
  • Faster feedback loops: The module graph is easier for dev tooling to track.
  • Safer refactors: Dependency relationships are visible instead of hidden behind runtime branching.

CommonJS is more flexible at runtime, but that flexibility comes with tradeoffs. If a file conditionally calls require(), a tool can’t always know ahead of time what the final dependency graph looks like.

Use CommonJS when you must interoperate with older code. Use ESM when you want your tools to understand your codebase, not just execute it.

The real team-level implications

For a single script, the ESM versus CommonJS difference might feel small. For a product team with multiple environments, shared packages, CI pipelines, and frontend builds, it becomes operational.

A few examples:

  • A design system published as ESM is easier for modern frontend tools to optimize.
  • A backend package still on CommonJS may force awkward interop code in newer projects.
  • A monorepo with mixed formats usually creates friction in tests, linting, and local scripts.
  • Shared utilities become easier to consume across browser and server code when both sides use the same module model.

Where developers usually get tripped up

The common mistake is assuming ESM is “just new syntax for require.” It isn’t. It’s a different contract.

In CommonJS, importing is tied to runtime execution. In ESM, the module graph is part of the program’s structure. That changes how bundlers optimize, how loaders resolve dependencies, and how asynchronous features fit into the language.

Another source of confusion is interoperability. Many projects still depend on packages written for CommonJS. So the transition period feels messy. That doesn’t mean ESM is the wrong direction. It means the ecosystem is still carrying historical baggage.

If you’re deciding what to use for a new project, the strategic answer is straightforward. Start with ESM unless a legacy dependency chain forces a different choice.

Unlock Advanced Power with Dynamic Imports and Top-Level Await

Basic import and export are enough for most modules. But ESM becomes much more interesting when you stop thinking of imports as something that always happens up front.

Two features matter a lot in production systems: dynamic import() and top-level await. They help you load code only when you need it and write asynchronous setup code without awkward wrappers.

A hand interacts with a blue Active Module button that triggers on-demand loading of modules in software.

Dynamic import for on-demand code loading

Say your app has a reporting screen that uses a heavy charting library. Loading that code on the first page view is wasteful if most users never open reports.

Dynamic import lets you defer it:

const button = document.querySelector('#show-report');

button.addEventListener('click', async () => {
  const { renderChart } = await import('./charts.js');
  renderChart();
});

That pattern is simple, but strategically important. It keeps initial code paths lighter and moves optional features behind actual user intent.

Good use cases include:

  • Admin-only features: Don’t load management tools for every user.
  • Rare flows: Export tools, reports, and wizards often fit dynamic loading well.
  • Environment-specific logic: Only import browser-only code in the browser.
  • Large integrations: Editors, chart libraries, and visualization packages are common candidates.

Top-level await removes old async boilerplate

Before top-level await, async startup code often ended up wrapped in an immediately invoked async function:

(async () => {
  const config = await loadConfig();
  startServer(config);
})();

With ESM, you can write:

const config = await loadConfig();
startServer(config);

That’s easier to read, and it reflects what the code is doing. The module can finish initialization before dependent work continues.

Team habit: Use top-level await for startup and configuration boundaries, not for random convenience in every file.

When these features help, and when they hurt

These tools are powerful, but they’re not free. If you overuse dynamic imports, your app can end up fragmented into too many tiny chunks. If you scatter top-level await across dependency chains, startup can become harder to reason about.

A practical approach:

  1. Use dynamic import where the feature is clearly optional.
  2. Keep startup awaits close to entry points.
  3. Avoid hiding critical application logic behind lazy boundaries unless the UX supports it.
  4. Test failure paths. A lazy-loaded module can fail at runtime, so your app needs a fallback.

One example from product work: if a user clicks “Show Report” and that module fails to load, the app shouldn’t just throw. It should show a clear UI state and let the user retry.

That’s the difference between knowing the feature and using it well. ESM gives you better primitives. You still have to apply them with product judgment.

Implementing ESM in Browsers and Node.js

A lot of developers understand the syntax and still get blocked when they try to run it. The issue usually isn’t JavaScript itself. It’s the environment.

Browsers and Node.js both support ESM, but they activate it differently. If you blur those rules together, you’ll waste time on errors that have nothing to do with your actual code.

A diagram comparing ESM modules in the browser and Node.js environments with code examples.

Using ESM in the browser

In the browser, the entry point is an HTML script tag with type="module":

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

That tells the browser to treat the file as an ES module. Inside app.js, you can use standard imports:

import { initApp } from './init.js';

initApp();

A few practical browser rules matter:

  • Use explicit file paths: Browser imports usually need the actual file path, including .js.
  • Think in URLs: The browser resolves module specifiers like resource paths.
  • Modules have their own scope: Variables don’t leak into the global scope the same way older scripts did.

That last point is a big win. It reduces accidental collisions and makes frontend codebases much easier to maintain.

Using ESM in Node.js

In Node.js, there are two common ways to tell the runtime you want ESM.

You can set this in package.json:

{
  "type": "module"
}

Or you can use the .mjs extension for module files.

For most applications, using "type": "module" is the cleaner path because it keeps filenames normal and makes the intent project-wide instead of file-by-file.

A basic Node.js module then looks like this:

import { readFile } from 'node:fs/promises';

const content = await readFile('./data.txt', 'utf8');
console.log(content);

If you’re building or deploying a server-side application, this overview of launching a website on Node.js is a useful operational companion.

Handling CommonJS interoperability

However, in reality, plenty of packages still use CommonJS, and many codebases are mixed.

A few patterns help:

  • Importing ESM from ESM: Smoothest path. Prefer this where possible.
  • Using a CommonJS package in ESM: Often works, but the import shape can be confusing.
  • Mixing file types in one repo: Possible, but usually a sign your migration is only half finished.

If you’re starting greenfield work, choose one module system per package and stick to it. Mixed-module codebases are where a lot of avoidable confusion starts.

Another difference that catches people is path utilities. In CommonJS, many developers relied on globals like __dirname. In ESM, you handle file location differently, so any migration touching filesystem code needs special attention.

The implementation advice is simple. Be explicit about your runtime, make your module format obvious at the project boundary, and test both local development and production startup early. Most “ESM problems” are really environment-configuration problems discovered too late.

A Strategic Guide to Migrating from CJS to ESM

Organizations don’t migrate from CommonJS to ESM because they’re excited about syntax. They migrate because supporting old module assumptions starts creating friction everywhere else. Tooling gets stranger. Shared packages get harder to publish cleanly. New dependencies assume modern behavior. The cost of staying put rises.

That means migration shouldn’t be framed as a search-and-replace task. It’s an architectural change, and it deserves a rollout plan.

Start with a dependency audit

Before changing your own files, check the packages around you. Some libraries already work smoothly in ESM. Others still expect CommonJS. A few support both but have edge cases.

Review:

  • Runtime dependencies: Check whether import style is straightforward or awkward.
  • Build tools: Confirm your bundler, test runner, and linter are happy with ESM.
  • Internal packages: Shared workspace packages often become the biggest source of inconsistency.
  • CLI scripts: These are easy to forget and often break first.

If your app is large, don’t migrate it all at once unless there’s a strong reason. A phased approach is usually safer.

Use a gradual migration boundary

A good modernization pattern is to replace one slice at a time while the old system keeps running. If that idea sounds familiar, it maps closely to the Strangler Fig Pattern, which is useful when you want to modernize without betting the whole system on one release.

For JavaScript modules, that often means converting one package, one service layer, or one app surface at a time instead of rewriting the entire dependency graph in a weekend.

A practical migration checklist

Here’s the checklist I’d use with a real team.

  1. Set the module boundary first
    Decide whether the package will use "type": "module" or a smaller .mjs trial. Don’t start converting imports before this decision is clear.

  2. Convert entry points early
    Your app bootstrap files, scripts, and package exports define the experience for everything downstream. Migrate them before random helper files.

  3. Replace require() with import carefully
    Straight conversions are easy. Conditional or computed imports need more thought and may become dynamic import().

  4. Replace module.exports and exports
    Move to named exports where possible. They tend to age better in shared code.

  5. Fix path and filename assumptions
    Filesystem-heavy code often needs cleanup because ESM doesn’t give you CommonJS globals in the same way.

  6. Run tests after each boundary change
    Don’t batch fifty file conversions and hope the failures are obvious.

The gotchas that usually consume the most time

Some migration problems repeat so often they’re worth calling out directly.

  • Default import confusion
    A CommonJS package may not map to the import shape you expect. The code may run, but the imported value may not be what you think.

  • Tooling mismatch
    The app may support ESM while your test runner or local scripts still assume CommonJS conventions.

  • Mixed exports inside shared utilities
    Helper libraries written over several years often contain inconsistent patterns. Standardizing them takes real cleanup work.

  • Hidden runtime assumptions
    Older code often relied on execution order or side effects that become more obvious during module conversion.

Migration goes faster when you treat every module as part of a dependency graph, not as an isolated file edit.

What a sane rollout looks like

A stable migration usually has these traits:

  • One owner or small working group sets the standards.
  • The team agrees on export style before converting dozens of files.
  • CI runs on every step.
  • Documentation gets updated with the new rules.
  • New code uses ESM immediately, even if old code is still being phased out.

That last point matters. Don’t let the repo stay in permanent indecision. Transitional states are fine. Indefinite ambiguity isn’t.

If you’re leading the migration, optimize for clarity over cleverness. Pick conventions. Write them down. Enforce them in linting and code review. Most module pain comes from inconsistency, not from ESM itself.

ESM and Your Development Toolchain

The strongest argument for ESM isn’t that import looks cleaner than require(). It’s that modern tooling can do more with it.

When your module system is static and predictable, tools can inspect the project graph without executing the whole app. That changes how bundlers, linters, test runners, and dev servers operate.

Why build tools prefer ESM

Tools like Vite, Rollup, and Webpack work best when they can answer simple questions early:

  • What files depend on this module?
  • What exports are used?
  • Can this file be excluded from the final bundle?
  • What needs to be reloaded after a change?

ESM makes those questions easier because imports and exports are declared in a fixed structure. The tool doesn’t have to guess whether a runtime branch will pull in another dependency later.

That’s the foundation for tree-shaking, where unused exports can be dropped from production bundles when the surrounding code allows it.

What this changes for real projects

For a frontend application, that often means smaller bundles and cleaner code splitting. For a Node.js codebase, it means a more consistent model across app code, shared packages, and utility scripts.

For teams, the toolchain improvements are just as important:

  • Faster hot updates: Dev servers can track module boundaries more precisely.
  • Better static checks: Linters and editors can reason about imports with less ambiguity.
  • Cleaner package publishing: Shared libraries don’t need as many compatibility contortions.
  • Simpler long-term maintenance: Fewer “why does this load differently in test versus build” problems.

A strong test setup matters here too. If your testing stack still assumes older loading patterns, you’ll feel friction quickly. This guide to the Node.js test runner is useful if you’re tightening the relationship between module format and test execution.

Toolchain decisions become architecture decisions

This is the part teams often underestimate. Choosing ESM affects more than import syntax. It influences package boundaries, bundle strategy, publishing format, and developer ergonomics.

Here’s a short decision lens:

Tooling concern Why ESM helps
Bundle optimization Static imports make unused code easier to remove
Local development Dev servers can map and reload modules more predictably
Shared libraries A standard module format reduces ambiguity across consumers
Code quality Linters and editors can analyze dependency edges more clearly

A module system is part of your platform design. If your tools can’t understand your code structure, every optimization becomes harder.

The teams that get the most from ESM usually do one extra thing well. They align module boundaries with product boundaries. Instead of giant utility buckets and vague cross-imports, they create packages and folders that reflect actual domains. Tooling then reinforces that structure instead of fighting it.

Why ESM Is the Future of JavaScript

ESM wins because it solves the right problem at the right layer. It gives JavaScript one standard module system that works across modern environments, fits current tooling, and supports cleaner asynchronous patterns without hacks.

That matters for day-to-day coding, but it matters even more for team operations. Codebases live longer than feature sprints. The module system you choose affects dependency management, publishing strategy, testing behavior, onboarding, and refactoring years after the original decision.

CommonJS still exists, and plenty of useful software still depends on it. But if you’re building something new, ESM is the direction that matches where the language and ecosystem are going. If you’re maintaining an older system, learning ESM gives you the vocabulary and mental model to modernize without guessing.

A key takeaway is simple. “What is ESM” isn’t just a beginner question. It’s a project strategy question. Teams that understand ESM well tend to make better decisions about architecture, tooling, and gradual modernization.


If you’re planning a new JavaScript platform or untangling an older CommonJS codebase, Nerdify can help you design the migration path, tighten your architecture, and build a codebase your team can maintain with confidence.