FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·117·13 MIN READ

117: Node Modules, `package.json`, npm, Semantic Versioning, and Workspaces

TOPICS COVERED: Node Modules, `package.json`, npm, Semantic Versioning, and Workspaces

Learning objectives

You will learn to:

  • distinguish ES modules and CommonJS;
  • configure an ESM-first Node project;
  • import/export local code and built-in modules;
  • resolve relative paths safely in ESM;
  • understand package.json, package-lock.json, dependencies, devDependencies, scripts, and engines;
  • install, update, audit, and execute packages;
  • read semantic-version ranges;
  • explain local versus global packages;
  • use npx/npm exec;
  • create package entry points with exports;
  • understand npm workspaces and monorepo basics;
  • avoid dependency and supply-chain mistakes.

These objectives are connected. A module system determines how code is loaded, package.json tells Node and npm how the project is meant to behave, and the lockfile records what was actually resolved. Once a project contains more than one package, workspaces add another layer of dependency boundaries. Understanding those relationships is more useful than memorizing isolated npm commands.

Start with an ESM project

Start with a small project so that the module behavior is explicit from the beginning:

bash
mkdir node-course
cd node-course
npm init -y

Edit package.json so it contains at least the following fields:

json
{
  "name": "node-course",
  "version": "1.0.0",
  "private": true,
  "type": "module"
}

The type field changes how Node interprets .js files in this package. With "type": "module", those files are ES modules rather than CommonJS files. That choice should be deliberate because it affects the syntax you use, how imports are resolved, and how older CommonJS code interoperates with the project.

ES modules

An ES module can expose a named function with export:

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

Another module imports that named export by name:

js
// app.js
import { add } from './math.js';

console.log(add(2, 3));

Run the application from the project directory:

bash
node app.js

The import path is relative to app.js, not to whichever directory happens to be the current working directory. In Node ESM, relative imports normally include their file extensions. ./math.js is therefore the dependable form; ./math should not be assumed to resolve as it might in a bundler or another tool.

Default exports

A module can also provide one default export:

js
export default function createLogger() {
  ...
}

The importing module chooses the local name for a default export:

js
import createLogger from './logger.js';

The useful design distinction is that named exports describe a module's public capabilities explicitly, while a default export represents its primary value. Prefer named exports for modules with several public capabilities. They make discovery and refactoring clearer because the imported name is tied to the exported name.

CommonJS

Much older Node code, and many existing packages, use CommonJS. Its equivalent patterns look like this:

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

module.exports = {
  ...
};

CommonJS is still supported; it has not disappeared just because a new project uses ESM. The issue is not that one syntax is universally valid and the other is not. The issue is that they have different loading and export rules.

Do not mix the syntaxes casually in the same package. Understand the interoperability rules when you need to consume older code, but choose one module system per package unless you have a specific publishing requirement for a carefully designed dual-package setup. An accidental mixture tends to produce confusing errors around require, import, default exports, and package interpretation.

ESM metadata

CommonJS traditionally provides these module-location globals:

text
__filename
__dirname

ESM provides the module URL instead:

js
console.log(import.meta.url);

Modern Node also exposes convenient import.meta path helpers in supported releases. Where portability across Node releases matters, the URL remains a clear baseline because it is part of the ESM model.

If code specifically needs filesystem path strings, the classic conversion is:

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

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);

There is a separate value that is easy to confuse with the module directory:

js
process.cwd()

The module directory answers:

Where is this module file?

The current working directory answers:

From which directory was this process launched?

Those directories can differ. A test runner, process manager, npm script, or deployment service may start the process from a directory other than the one containing the source file. Use the module location when resolving a resource that belongs beside the module; use process.cwd() when the behavior intentionally depends on the launch directory.

Dynamic import

ESM can load a module at runtime with a dynamic import:

js
const module = await import('./optional-feature.js');

This is useful for:

  • lazy optional features;
  • conditionally loaded plugins;
  • large modules not always needed.

Dynamic import returns a promise, so the surrounding code must account for asynchronous loading. It also changes when module loading occurs. That can be valuable for an optional feature, but it is not a substitute for a clear module architecture. If a dependency is always required, a normal static import makes the dependency and startup behavior easier to understand.

package.json

package.json is the project manifest. It gives npm package identity and dependency information, and it can also describe the module system, supported Node versions, and commands that the project exposes to developers and automation.

A production package can include:

json
{
  "name": "@acme/task-api",
  "version": "2.3.1",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24 <27"
  },
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "node --test",
    "lint": "eslint ."
  },
  "dependencies": {},
  "devDependencies": {}
}

Each field has a different responsibility. name and version identify the package, type selects the interpretation of .js files, engines documents the supported Node range, and scripts gives the team repeatable commands. The two dependency sections communicate which packages are needed at runtime and which are only needed while developing or verifying the project.

private: true prevents accidental publication through npm. It is a useful safeguard for application repositories that are not intended to be published as reusable packages.

Dependencies versus devDependencies

Install a package needed by the running application like this:

bash
npm install express

It is normally recorded under:

json
"dependencies"

Install development-only tooling like this:

bash
npm install --save-dev eslint

That package is recorded under:

json
"devDependencies"

The distinction is about the deployment contract, not about whether a developer happens to run the command locally. If production code imports a package, put it in dependencies. Otherwise a deployment that installs only production dependencies may omit it and fail at startup. Linters, test runners, formatters, and build tools are common devDependencies when the deployed application does not need them to run.

Lockfile

package-lock.json records resolved dependency-graph information. The manifest may describe an allowed version range, while the lockfile records the concrete versions and integrity information selected for this project, including transitive dependencies.

Commit the lockfile for applications. It provides:

  • repeatable installs;
  • explicit transitive versions;
  • integrity metadata;
  • deterministic CI behavior.

Use this command in CI or deployment environments where the lockfile should be installed exactly:

bash
npm ci

Unlike a normal install workflow that may update the lockfile, npm ci expects the package metadata and lockfile to agree. It fails when they disagree rather than silently rewriting the lockfile. That failure is useful: it tells the team that the dependency description and the recorded resolution need to be reconciled before deployment.

npm scripts

Scripts turn project operations into named, reviewable commands:

json
{
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test",
    "check": "npm run lint && npm test"
  }
}

Run them with npm:

bash
npm start
npm test
npm run check

Executables installed by local packages in node_modules/.bin are made available to npm scripts. That is why a script can invoke a locally installed eslint without requiring a global installation or a machine-specific PATH configuration.

You normally do not need a global install for project tooling. Keeping the command in package.json also gives CI and other developers the same documented entry point.

Local versus global installation

A local install belongs to the project's dependency graph:

bash
npm install eslint --save-dev

A global install targets the user's or system's environment:

bash
npm install -g some-cli

Global installation can be appropriate for a CLI that you intentionally use across unrelated projects, but project build tooling should normally be local. Local tooling means:

  • CI gets the same version;
  • the team does not depend on machine-specific global state;
  • the package version is documented.

If a command works only because one developer installed a particular global version, the project has an undocumented dependency. That is exactly the kind of setup that fails on a clean CI runner.

npx / npm exec

npx can run a package executable without a permanent global install:

bash
npx eslint .

Modern npm also provides the npm exec forms for this purpose. The convenience comes with a security boundary: executing an npm package executes code on your machine. Be cautious when running a package name you have not reviewed, especially when the command may download it temporarily. Prefer a known local project dependency when the command is part of a repeatable workflow, and inspect unfamiliar packages before executing them.

Semantic Versioning

Semantic Versioning, usually written SemVer, represents a version as:

text
MAJOR.MINOR.PATCH

The conceptual meanings are:

text
PATCH  bug-compatible fix
MINOR  backwards-compatible feature
MAJOR  breaking change

For example, a dependency declaration might use a caret range:

json
"express": "^5.1.0"

For normal 1.0.0+ packages, a caret generally allows compatible updates below the next major version. It does not mean that every future release is safe for your application; compatibility is a package author's promise, and your application still needs tests and review.

A tilde range is narrower:

json
"some-package": "~2.4.3"

An exact version allows no range:

json
"some-package": "2.4.3"

The lockfile still records the concrete installed version even when package.json contains a range. Do not assume that every package in the ecosystem follows SemVer perfectly. Read migration notes and test major upgrades instead of treating the version number as a complete risk assessment.

Inspect dependency graph

When behavior or bundle size changes unexpectedly, inspect what is actually installed rather than looking only at the top-level manifest.

List the dependency tree:

bash
npm ls

Find packages with available updates:

bash
npm outdated

Inspect registry metadata, such as the current Express version:

bash
npm view express version

Ask why a transitive package is present:

bash
npm explain some-package

These commands answer different questions: what is installed, what could change, what the registry reports, and which dependency path brought a package into the tree. npm commands evolve, so verify the help output for the npm version installed in your environment.

Security audit

Run npm's audit report with:

bash
npm audit

Treat the report as input to engineering judgment, not as an instruction to apply every suggested command. A reported vulnerability may affect a code path your application never uses, or the available fix may be a breaking major upgrade.

For each finding, evaluate:

  • vulnerable package;
  • whether vulnerable code path is used;
  • patch/minor/major update;
  • upstream status;
  • compensating controls;
  • deployment exposure.

Do not blindly run destructive force upgrades that introduce breaking majors. Keep dependencies current, but review the resulting application behavior, lockfile changes, and deployment impact as part of the update.

Supply-chain risk

Installing a package means trusting both its code and its dependency graph. The package may run code during installation, during application startup, or when one of its APIs is called. Popularity alone does not establish that it is the right dependency for your project.

Before adding a package, ask:

  • can built-in Node APIs solve this?
  • is project maintained?
  • release cadence?
  • package ownership changes?
  • download/popularity is not proof of safety;
  • dependency count?
  • license?
  • TypeScript/types if needed?
  • security history?

For tiny utility behavior, a built-in function may be safer than adding 40 transitive dependencies. The trade-off is not simply fewer lines of code versus more lines of code; it includes maintenance, licensing, update burden, attack surface, and whether the dependency is trustworthy and necessary.

exports

An internal or published package can define the entry points that consumers are allowed to use:

json
{
  "name": "@acme/shared",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./errors": "./src/errors.js"
  }
}

Consumers use those declared paths:

js
import { something } from '@acme/shared';
import { AppError } from '@acme/shared/errors';

The exports map creates an explicit public surface. It allows the package maintainers to change unlisted internal files without promising that consumers can import them. Without that boundary, consumers may reach into internal paths and turn implementation details into accidental API.

imports

A package can define private import aliases with the imports field:

json
{
  "imports": {
    "#config": "./src/config.js",
    "#lib/*": "./src/lib/*.js"
  }
}

Code inside the package can then use an alias:

js
import { config } from '#config';

These aliases avoid long relative paths, but they add another resolution convention for readers to learn. Use them sparingly and consistently. They are private package-internal paths, unlike the consumer-facing entry points described by exports.

Package entry design

An entry module is part of the package's startup and dependency graph. Avoid a barrel that eagerly imports the entire application:

js
// index.js imports database, server, jobs, workers...

That design is wasteful when a consumer needs only one small utility. It can also trigger side effects, increase startup cost, complicate tree structure, and make test isolation harder.

Keep package boundaries intentional. Expose the smallest useful public surface, and avoid making every internal module reachable merely because it is convenient to re-export it.

Side effects during import

Importing a module should not unexpectedly start infrastructure unless the architecture deliberately depends on that behavior. For example, avoid this implicit database connection:

js
// db.js
connectToDatabase();

Prefer an exported operation:

js
export async function connectDatabase() {
  ...
}

Then call it during application composition, where startup order and failure handling are visible. This makes tests easier to set up and tear down, and it prevents a harmless import from opening a database connection or starting a server before the application is ready.

npm workspaces

Workspaces let one npm-managed repository contain multiple packages. The root manifest might look like this:

json
{
  "name": "platform",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

One possible structure is:

text
platform/
├─ apps/
│  ├─ api/
│  └─ worker/
└─ packages/
   ├─ config/
   └─ domain/

Workspaces help manage multiple packages in one repository, including their installation and relationships. They are useful when there is a real multi-package boundary. Do not add them just to make a small application look “enterprise”; they add structure and decisions that need to be maintained.

Workspace dependency boundaries

A shared package should not depend back on an application. The intended direction might be represented as:

text
packages/domain
  ↓
apps/api

The reverse dependency is a boundary violation:

text
packages/domain
  → apps/api/internal-controller

Keep dependency direction acyclic and understandable. If a supposedly shared domain package imports an application's internal controller, it is no longer independent and cannot be reused cleanly by another application or tested in isolation.

Publishing basics

A reusable package needs:

  • intentional public API;
  • versioning;
  • license;
  • files included in package;
  • build output if compiled;
  • tests;
  • changelog/release process.

These are part of the package's contract with consumers, not merely release paperwork. Application-only code can remain "private": true, which prevents accidental publication while the application is not intended to be consumed as a package.

Common mistakes

Missing .js extension in ESM relative import

This relative import is incomplete for normal Node ESM resolution:

js
import './config';

It may fail depending on the Node resolution context. Use the explicit filename:

js
import './config.js';

If a bundler accepts the extensionless form, that does not necessarily mean Node will accept it when running the source directly.

Confusing cwd and module directory

Tests and process managers can launch a program from different working directories. A path based on process.cwd() can therefore point somewhere different from a path based on the module's location. Identify which boundary the resource belongs to before choosing the base path.

Installing project tools globally

Global tooling creates hidden machine state. A clean checkout or CI runner may not have the expected command or may have a different version. Keep project tooling local and invoke it through npm scripts when it belongs to the project.

Deleting lockfile to “fix” dependency conflicts

Deleting the lockfile usually hides the underlying version problem and causes a fresh resolution. Inspect the dependency tree and reconcile the manifest and lockfile instead of discarding the evidence of what was previously installed.

Running npm audit fix --force without review

The force option can introduce breaking upgrades. Read the audit finding, understand the vulnerable path, and review the proposed dependency changes before accepting a major-version jump.

Import-time database/server startup

Starting infrastructure as an import side effect creates test and lifecycle problems. It makes import order significant and can open connections or bind ports before the application composition code has configured the process.

Exercises

  1. Convert a CommonJS two-file example to ESM.
  2. Print module URL and process cwd, then launch script from another directory.
  3. Create npm scripts for dev/test/start.
  4. Install one runtime and one development dependency.
  5. Inspect the lockfile and npm ls.
  6. Create a package with an exports map.
  7. Build a two-workspace monorepo with one shared package.
  8. Review five dependencies and justify whether each should exist.

These exercises move from identifying syntax to observing runtime behavior, then to making dependency and package-boundary decisions. For each one, keep the project files and command output available so you can explain not only what worked, but why Node or npm behaved that way.

Mastery checklist

Explain:

  • ESM versus CommonJS;
  • module location versus cwd;
  • dependencies versus devDependencies;
  • lockfile and npm ci;
  • SemVer ranges;
  • local versus global packages;
  • exports;
  • workspaces;
  • supply-chain risk;
  • why import-time side effects complicate systems.

You should be able to use this checklist diagnostically. If an application fails to find a file, start with module location versus cwd; if a deployment fails to import a package, inspect dependency classification and the lockfile; if a package is difficult to update safely, inspect its public entry points, dependency graph, and version range.

Official references

Reader page: /nodejs/lesson/117/node-modules-package-json-npm-semantic-versioning-and-workspaces