articles / JavaScript Modules and Packages: From Script Tags to ESM

JavaScript Modules and Packages: From Script Tags to ESM

JavaScript Modules and Packages: From Script Tags to ESM

For most of its first decade, JavaScript had no built-in way to split a program across files. Code was loaded with a series of <script> tags, every file shared one global namespace, and the order of those tags decided whether anything worked. Sharing reusable code between projects meant copying files by hand or pulling them from a public listing of libraries. That worked until projects grew past a few hundred lines, and then it stopped working.

The modern answer comes in two parts: a module system built into the language, and a package manager that resolves and installs dependencies. Understanding how the two fit together makes a lot of day-to-day tooling feel less like magic.

The problem modules solve

A module is a file with its own scope. Names declared inside it do not leak into the global namespace unless they are explicitly exported, and code from other files becomes available only when it is explicitly imported. That single rule removes a whole category of bugs: accidental name collisions, load-order fragility, and the question of which script defined a given global.

ES modules in practice

Native ES modules (ESM) use export and import. A file exports the values other files are allowed to use, and consumers name exactly what they need.

// math.js
export function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

// app.js
import { clamp } from './math.js';
console.log(clamp(120, 0, 100)); // 100

In the browser, a module is loaded with <script type="module" src="app.js">. Module scripts are deferred by default and run in strict mode, so a lot of older footguns simply do not apply. On the server, Node.js supports the same syntax for files using the .mjs extension or a package marked "type": "module".

Named exports are easy to refactor and tree-shake. A default export is convenient for a single-purpose file, but it makes automated renaming harder because each importer can pick its own local name.

Where packages come in

A package is a directory described by a package.json file: a name, a version, an entry point, and a list of dependencies. A package manager reads that file, works out the full dependency tree, downloads each package, and records exact resolved versions in a lock file so the next install is reproducible.

{
  "name": "my-app",
  "type": "module",
  "dependencies": {
    "date-fns": "^3.6.0"
  }
}

Running an install command pulls date-fns and anything it depends on into node_modules, then writes a lock file. Versions follow semantic versioning, where the three numbers signal breaking changes, new features, and fixes. The caret in ^3.6.0 allows compatible updates within the same major version while refusing a breaking jump to version 4.

Bundlers and the build step

Browsers can load modules directly, but a real application often imports dozens of small files, and requesting each one separately is slow. A bundler resolves the import graph ahead of time and produces a small number of optimized files. It can also drop code that is never imported, a process called tree-shaking, which is far more effective with named ESM exports than with the older patterns it replaced.

CommonJS and the two-module-systems problem

Before ES modules were standardized, Node.js shipped its own system called CommonJS, which uses require() to load a module and module.exports to expose values. A large amount of existing code still uses it, so both systems coexist, and the difference occasionally leaks into a project. CommonJS loads synchronously and resolves imports at run time, while ES modules are static and analyzed before the code runs. That static nature is what lets bundlers tree-shake reliably.

// CommonJS (older Node style)
const { clamp } = require('./math.js');
module.exports = { clamp };

Most tooling can interoperate between the two, but the safest path for new code is to pick ES modules and stay consistent. A single "type": "module" in package.json sets the default for a whole project, and the occasional file that genuinely needs the other format can opt out with a .cjs extension. Mixing the two casually inside one package is where confusing errors tend to appear.

Pinning down dependencies

The lock file is easy to overlook but matters more than the version ranges in package.json. The ranges describe what is acceptable; the lock file records exactly what was installed, down to the resolved version of every transitive dependency. Committing it to version control is what makes a build on another machine, or months later, produce the same result. Skipping it is a common cause of the it-works-on-my-machine class of bug.

The practical takeaway is that imports, package metadata, and the build step are one connected system. Declare dependencies honestly in package.json, prefer named exports, lean on the lock file for reproducible installs, and let the bundler handle the rest.

Common questions

What is the difference between a module and a package?

A module is a single file with its own scope that exports and imports specific values. A package is a versioned directory of one or more modules described by a package.json file, which a package manager can install and resolve.

Should I use named exports or default exports?

Named exports are usually the better default. They make automated refactoring and tree-shaking more reliable because every importer refers to the same name. A default export is fine for a file that exposes a single thing.

Do browsers need a bundler to use ES modules?

No. Browsers load ES modules natively with a script tag marked type="module". A bundler is a performance optimization for larger apps that import many small files, not a requirement.

What does the caret in a version like ^3.6.0 mean?

It allows updates that stay within the same major version, so any 3.x release at or above 3.6.0 is acceptable, but a breaking 4.0.0 is not installed automatically.