Modules & Package Management

1. Introduction đŸ“Ļ

Every non-trivial Node.js application is built from modules — reusable pieces of code that can be shared, versioned, and composed. This tutorial covers how Node's module systems work, how npm resolves and manages dependencies, and how to publish your own packages.

Information

This tutorial assumes basic familiarity with Node.js fundamentals such as the runtime and the event loop.

2. What are Modules? 🧩

A module is simply a file (or set of files) whose code is encapsulated in its own scope. Variables and functions declared inside a module are private by default — they must be explicitly exported to be used elsewhere.

  • Encourages separation of concerns and reusability.
  • Avoids polluting the global namespace.
  • Node supports two systems: CommonJS and ES Modules.

3. CommonJS Modules 📜

CommonJS (CJS) is Node's original module system. Every .js file (without "type": "module") is treated as a CJS module, wrapped automatically in a function that provides require, module, and exports.

greet.js

function greet(name) {
  return `Hello, ${name}!`;
}

module.exports = { greet };

4. ES Modules 🌐

ES Modules (ESM) are the standardized JavaScript module format, also used in browsers. Node treats .mjs files — or .js files inside a package with "type": "module" — as ESM.

greet.mjs

export function greet(name) {
  return `Hello, ${name}!`;
}

Important

ESM is parsed statically, which enables tree-shaking and top-level await, but it is asynchronous by nature — unlike CJS's synchronous loading.

5. Core Syntax Reference 🔤

This section covers the individual keywords and functions used across both module systems.

5.1 require()

require() synchronously loads and returns the exports of another module. Results are cached — calling require() on the same path twice returns the same object.

app.js

const { greet } = require('./greet');
console.log(greet('Node'));

5.2 module.exports

module.exports is the actual object returned by require(). You can assign to it directly to export a single value (a function, class, or object).

single-export.js

module.exports = function add(a, b) {
  return a + b;
};

5.3 exports

exports is a shorthand reference to module.exports. You can attach properties to it, but reassigning exports directly breaks the reference — always reassign module.exports instead.

exports-pitfall.js

exports.add = (a, b) => a + b; // ✅ works

exports = { subtract: (a, b) => a - b }; // ❌ breaks the link to module.exports

Caution

If you reassign exports instead of module.exports, the module will export an empty object.

5.4 import

import-example.mjs

import { greet } from './greet.mjs';
import defaultExport from './config.mjs';

5.5 export

export-example.mjs

export const PI = 3.14159;
export function square(x) {
  return x * x;
}

5.6 Default Exports

A module can have one default export, imported without curly braces and under any name you choose.

config.mjs

export default {
  port: 3000,
  host: 'localhost',
};

5.7 Named Exports

A module can have multiple named exports, each imported by its exact name (or renamed with as).

named-import.mjs

import { PI, square as sq } from './math.mjs';

5.8 Dynamic Imports

import() is a function-like operator that asynchronously loads a module, returning a Promise. It works in both CJS and ESM contexts and is ideal for code-splitting or conditional loading.

dynamic-import.js

async function loadFeature(flag) {
  if (flag) {
    const { feature } = await import('./feature.mjs');
    feature();
  }
}

6. Resolution 🔍

6.1 Module Resolution

When you require() or import a path, Node follows a specific resolution algorithm to locate the actual file.

Resolve Specifier
Starts with "./" or "../"? → Resolve as relative file path
Starts with "/"? → Resolve as absolute path
Bare specifier (e.g. "lodash")? → Package Resolution
Search node_modules upward through directories

6.2 Package Resolution

For bare specifiers like require('express'), Node searches for a node_modules folder in the current directory, then walks up the directory tree until it finds a match or reaches the filesystem root.

6.3 Local Modules

Local modules are files within your own project, referenced with relative paths (./utils, ../lib/db).

6.4 Third-Party Modules

Third-party modules are packages installed from a registry (typically npm) and live inside node_modules.

6.5 Built-in Modules

Node ships with built-in modules like fs, path, http, and crypto. These can be imported with or without the node: prefix (e.g. node:fs), which is recommended to avoid ambiguity with user packages of the same name.

builtin-example.js

const fs = require('node:fs');
const path = require('node:path');

7. Package Configuration đŸ—‚ī¸

7.1 package.json

package.json is the manifest file describing your project: its name, version, dependencies, scripts, and entry points.

package.json

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

7.2 package-lock.json

package-lock.json records the exact version of every installed package (including nested dependencies), ensuring reproducible installs across machines and CI environments.

Best Practice

Always commit package-lock.json to version control.

7.3 Semantic Versioning

Semantic Versioning (SemVer) follows the pattern MAJOR.MINOR.PATCH.

SymbolMeaningExample
^1.2.3Compatible with minor/patch updatesAllows 1.9.0, not 2.0.0
~1.2.3Allows only patch updatesAllows 1.2.9, not 1.3.0
1.2.3Exact version onlyOnly 1.2.3
*Any versionNot recommended

8. Managing Packages 🔧

8.1 Installing Packages

install.sh

npm install express        # add as a dependency
npm install -D typescript  # add as a devDependency
npm install                # install everything from package.json

8.2 Updating Packages

update.sh

npm outdated       # see which packages have newer versions
npm update         # update within semver ranges
npm install express@latest  # force upgrade to latest

8.3 Removing Packages

remove.sh

npm uninstall express

8.4 npm Scripts

The scripts field in package.json lets you define shortcuts for common tasks, runnable via npm run <script>.

run-scripts.sh

npm run start
npm run test
npm run build

8.5 Workspaces

Workspaces let you manage multiple related packages (a monorepo) from a single root package.json, sharing a single node_modules and linking local packages together.

package.json (root)

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}
my-monorepo
package.json
packages

9. Publishing & CLI Packages đŸšĸ

9.1 Publishing Packages

Publishing makes your package available on the npm registry for others to install.

9.2 Creating CLI Packages

A CLI package exposes an executable command. Add a bin field to package.json pointing to a script with a shebang line.

bin/cli.js

#!/usr/bin/env node

console.log('Hello from my CLI!');

package.json

{
  "name": "my-cli",
  "bin": {
    "my-cli": "./bin/cli.js"
  }
}

Tip

After publishing, users can run your tool globally with npx my-cli or after npm install -g my-cli.

10. Best Practices ✅

  • Pin dependency versions carefully; commit package-lock.json.
  • Prefer node:-prefixed imports for built-in modules.
  • Use exports field in package.json to control what's publicly accessible from your package.
  • Avoid mixing CJS and ESM in the same package without clear "type" configuration.
  • Run npm audit regularly to catch known vulnerabilities.

11. Common Mistakes âš ī¸

  • Reassigning exports instead of module.exports, silently breaking exports.
  • Forgetting "type": "module" when using import/export syntax in .js files.
  • Using overly loose version ranges (* or latest) in production dependencies.
  • Deleting package-lock.json "to fix" install issues, causing irreproducible builds.
  • Publishing a package without a .npmignore or files field, shipping unnecessary files.

12. Frequently Asked Questions ❓

Question

Can I use require() inside an ES Module?

Answer

Not directly — require is not defined in ESM by default. You can create one using createRequire from node:module if needed.

Question

What happens if I don't have a package-lock.json?

Answer

npm install will resolve versions fresh each time based on the ranges in package.json, which can lead to inconsistent installs across environments.

Question

Is it safe to mix CommonJS and ES Modules in one project?

Answer

Yes, with care — ESM can import CJS modules, but CJS cannot require() ESM directly; use dynamic import() instead.

13. Summary 📝

Summary

Node.js supports two module systems — CommonJS and ES Modules — each with its own syntax for exporting and importing code. npm and package.json manage dependencies using Semantic Versioning, while package-lock.json guarantees reproducible installs. Understanding module resolution, proper use of exports, and packaging conventions like bin and workspaces prepares you to build, consume, and publish robust Node packages.