Module: Nodejs
Nodejs·117·5 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.

Start with an ESM project

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

Edit:

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

Now .js files in this package are interpreted as ES modules.

ES modules

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

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

Run:

bash
node app.js

Node ESM relative imports normally include file extensions.

Default exports

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

Import:

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

Prefer named exports for modules with several public capabilities because refactoring and discovery can be clearer.

CommonJS

Older Node code often uses:

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

module.exports = {
  ...
};

CommonJS still exists and many packages use it.

Do not mix syntaxes casually.

Understand interoperability, but choose one module system per package unless publishing requires careful dual-package design.

ESM metadata

CommonJS traditionally has:

text
__filename
__dirname

ESM has:

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

Modern Node also exposes convenient import.meta path helpers in supported releases; where portability matters, URLs remain a clear baseline.

Classic conversion:

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

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

This is different from:

js
process.cwd()

Module directory answers:

Where is this module file?

Current working directory answers:

From which directory was this process launched?

Those can differ.

Dynamic import

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

Useful for:

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

Do not use dynamic import as a substitute for normal architecture.

package.json

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": {}
}

Fields have different responsibilities.

private: true prevents accidental publication through npm.

Dependencies versus devDependencies

Install runtime package:

bash
npm install express

Usually recorded under:

json
"dependencies"

Development-only tooling:

bash
npm install --save-dev eslint

under:

json
"devDependencies"

Do not place a package in devDependencies if production runtime imports it and your deployment installs only production dependencies.

Lockfile

package-lock.json records resolved dependency graph information.

Commit it for applications.

Benefits:

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

Use:

bash
npm ci

in CI/deployment environments where the lockfile should be installed exactly.

npm ci fails when package metadata and lockfile disagree rather than silently rewriting the lockfile.

npm scripts

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

Run:

bash
npm start
npm test
npm run check

Local package executables installed in node_modules/.bin are available to npm scripts.

You normally do not need a global install for project tooling.

Local versus global installation

Local:

bash
npm install eslint --save-dev

belongs to project dependency graph.

Global:

bash
npm install -g some-cli

installs a CLI for the user/system environment.

Project build tooling should normally be local so:

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

npx / npm exec

Run a package executable without a permanent global install:

bash
npx eslint .

or use modern npm exec forms.

Be cautious when running a package name you have not reviewed. Executing an npm package executes code on your machine.

Semantic Versioning

Version:

text
MAJOR.MINOR.PATCH

Conceptually:

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

Common ranges:

json
"express": "^5.1.0"

Caret generally allows compatible updates below the next major for normal 1.0.0+ packages.

Tilde:

json
"some-package": "~2.4.3"

is narrower.

Exact:

json
"some-package": "2.4.3"

allows no range.

The lockfile still records the concrete installed version.

Do not assume all ecosystem packages follow SemVer perfectly. Read migration notes for major upgrades.

Inspect dependency graph

bash
npm ls

Outdated:

bash
npm outdated

Package metadata:

bash
npm view express version

Explain why a package exists:

bash
npm explain some-package

Commands evolve with npm; verify help for your installed version.

Security audit

bash
npm audit

Treat audit output as input to engineering judgment.

Do not blindly run destructive force upgrades that introduce breaking majors.

Evaluate:

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

Keep dependencies current.

Supply-chain risk

Installing a package means trusting its code and dependency graph.

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.

exports

Published/internal packages can define supported entry points:

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

Consumers:

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

exports creates an explicit public surface and can prevent consumers from depending on internal files.

imports

A package can define private import aliases:

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

Usage:

js
import { config } from '#config';

Use sparingly and consistently.

Package entry design

Avoid a “barrel” that eagerly imports the entire application:

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

when consumers only need one small utility.

Module boundaries affect startup cost, side effects, tree structure, and test isolation.

Side effects during import

Avoid:

js
// db.js
connectToDatabase();

as an implicit import side effect unless architecture deliberately expects it.

Prefer:

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

and call during application composition.

This makes tests and startup ordering easier.

npm workspaces

Root:

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

Structure:

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

Workspaces help manage multiple packages in one repository.

Use them when there is a real multi-package boundary, not to make a small app look “enterprise.”

Workspace dependency boundaries

A shared package should not import back into an application:

text
packages/domain
  ↓
apps/api

not:

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

Keep dependency direction acyclic and understandable.

Publishing basics

A reusable package needs:

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

Application-only code can stay "private": true.

Common mistakes

Missing .js extension in ESM relative import

js
import './config';

may fail depending on Node resolution context.

Use explicit:

js
import './config.js';

Confusing cwd and module directory

Tests and process managers can launch from different working directories.

Installing project tools globally

Creates hidden machine state.

Deleting lockfile to “fix” dependency conflicts

Usually hides the underlying version problem.

Running npm audit fix --force without review

Can introduce breaking upgrades.

Import-time database/server startup

Creates test and lifecycle problems.

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.

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.

Official references