Node.js Essentials: Modules and npm
What you’ll learn
Node.js essentials start with modules — the building blocks of any Node.js application. They let you organize code into reusable pieces, share functionality across files, and tap into thousands of packages from the npm ecosystem. You will learn how to create modules, import them into other files, and manage dependencies with npm.
Understanding modules in Node.js
Every JavaScript file in Node.js is treated as a module. When you create a file called greeting.js, it becomes a module that can be imported into other files. Node.js uses the CommonJS module system by default, which provides require() for importing and module.exports for exporting.
Creating your first module
Create a new file called greeting.js and add a simple function:
// greeting.js
function sayHello(name) {
return `Hello, ${name}!`;
}
module.exports = sayHello;
The module.exports statement makes whatever you assign available to other files when they import this module. You can export functions, objects, classes, or any JavaScript value.
Importing a module
Now create an index.js file in the same directory and import your greeting function:
// index.js
const sayHello = require('./greeting');
console.log(sayHello('World'));
// Output: Hello, World!
When you run node index.js, Node.js loads greeting.js, executes it, and returns whatever was assigned to module.exports. The ./ prefix tells Node.js to look in the current directory.
Exporting multiple values
Sometimes you want to export multiple functions or values from a single module. There are two common patterns for this.
Pattern 1: export an object
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
module.exports = {
add,
subtract,
multiply
};
Exporting an object lets consumers pull everything under one namespace. This pattern keeps imports tidy when a module exposes several related functions, and the namespace prefix in the calling code makes the origin of each function immediately visible — the math. prefix in the calling code makes the origin of each function immediately visible.
// app.js
const math = require('./math');
console.log(math.add(5, 3)); // Output: 8
console.log(math.multiply(4, 2)); // Output: 8
Pattern 2: destructuring on import
Instead of importing the entire module object, destructuring pulls specific exports directly into local variables. This keeps call sites short when you only need a subset of what the module offers. The trade-off is that bare function names lose the module namespace, which can make it harder to trace which file each function came from in larger codebases.
const { add, multiply } = require('./math');
console.log(add(10, 5)); // Output: 15
console.log(multiply(3, 7)); // Output: 21
Destructuring pulls only the named exports you need directly into local variables. The trade-off is clarity — when a file imports from six modules, bare function names like add() can be ambiguous without the module prefix. For small modules with obvious names, the shorter form reads well.
Both patterns work well. The choice depends on your preference and how many exports you need.
Introduction to npm
npm (Node Package Manager) is the default package manager for Node.js. It comes bundled with Node.js and gives you access to a massive registry of open-source packages. npm also helps you manage your project’s dependencies.
Initializing a new project
Run npm init in your project directory to create a package.json file:
npm init
This interactive command asks several questions about your project, including the name, version, description, entry point, test command, git repository, keywords, author, and license. You can also use npm init -y to accept all defaults and skip the interactive prompts. The resulting package.json file is a JSON document that npm and Node.js tooling read to understand your project structure, scripts, and dependency tree.
{
"name": "my-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
The package.json file tracks your project’s metadata and dependencies. Understanding its structure is essential for Node.js development. The main fields include “name” for your project identifier, “version” following semantic versioning (major.minor.patch), “main” pointing to your entry file, and “scripts” defining automation commands. The “dependencies” section lists packages required at runtime, while “devDependencies” lists packages only needed during development.
Installing packages
Install a package using npm install package-name. For example, to add the popular lodash library:
npm install lodash
This does two things: it downloads the package into a node_modules folder, and it adds the dependency to package.json with a version range that governs future installs. The version recorded in package.json uses a caret range so npm can pull compatible updates automatically without any manual intervention.
{
"dependencies": {
"lodash": "^4.17.21"
}
}
The caret prefix in ^4.17.21 accepts minor and patch bumps up to (but not including) 5.0.0. That keeps you on a stable major while still picking up bug fixes. The exact resolved version is locked in the lockfile for reproducibility.
Using installed packages
Once installed, you can import packages just like local modules:
const _ = require('lodash');
const numbers = [1, 2, 3, 4, 5];
console.log(_.sum(numbers)); // Output: 15
console.log(_.shuffle(numbers)); // Output: [3, 1, 5, 2, 4] — random order
lodash is a runtime dependency: your production code calls it directly when serving requests. Not every package belongs in the deployed bundle. Tools you only use during development, like test runners and linters, should live in a separate dependency bucket so the production install stays lean. npm enforces this split through the —save-dev flag.
Development dependencies
Packages you only need during development (like testing frameworks) should be installed as dev dependencies:
npm install --save-dev jest
These appear in a separate section of package.json, keeping production and development dependencies clearly separated in the manifest. This distinction matters when deploying: npm install —production skips devDependencies entirely, producing a smaller node_modules tree for your server or container image.
{
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"jest": "^29.0.0"
}
}
Separating the two dependency types keeps the production install lighter. When you run npm install --production, devDependencies are skipped entirely, reducing the node_modules footprint on your server or container image. This distinction matters most in CI pipelines and Docker builds where every megabyte in the image costs real deploy time.
Understanding node_modules
The node_modules folder contains all packages your project depends on, including their own dependencies. This folder can grow quite large as you install more packages, but you should never commit it to version control. Including node_modules in your repository would bloat the repository size significantly and cause unnecessary conflicts between team members who may have different operating systems or package versions.
Add node_modules to your .gitignore file:
echo "node_modules/" >> .gitignore
This appends the ignore rule to an existing .gitignore file, or creates one if it does not exist. For new projects, writing the .gitignore by hand gives you more control over what files to exclude beyond just node_modules — build artifacts, environment files, and OS metadata are worth adding too. If you prefer to manage the file directly, create it with these contents:
node_modules/
When you clone a repository, run npm install (or npm ci for CI/CD environments) to reinstall all dependencies from the package.json lock file. The lock file ensures everyone on your team gets the exact same versions of all packages, preventing the “it works on my machine” problem.
Summary
You have learned how to create and import modules in Node.js using require() and module.exports. You have also seen how npm helps you manage project dependencies, install packages, and organize your code. These fundamentals are essential for building any Node.js application.
In the next tutorial of this series, you will learn how to work with the file system in Node.js.
Keep package.json trustworthy
package.json is the project contract. It tells npm what the app needs, which scripts exist, and how the package should be identified. Keep it tidy and review changes carefully, because a small edit there can change install behavior across the whole team. That file is often the first place to look when dependency versions or scripts feel off.
Know when to use local files
A local module is the right choice when code only belongs to one project. A package dependency is the right choice when you want versioned behavior from outside the repo. That split helps you avoid copying utility code from file to file and makes it easier to replace a package later if the project needs a different path.
Keep your workflow small
The simplest npm workflow is often the best one: initialize, install, run, and update. Learn the few commands you use every week, then keep the rest of the process visible in package scripts. That reduces the amount of shell ceremony you need to remember and makes new teammates faster when they open the project.
Review lockfiles together
When dependencies change, the lockfile should travel with the package manifest. Reviewing them together makes it easier to see whether the update is expected or accidental. That habit also helps you catch package churn before it becomes a larger maintenance issue.
Keep script names stable
Scripts become part of the team’s muscle memory. If test, build, and start always mean the same thing, it is much easier to hand the project to someone new. Stable names turn the project into a set of predictable commands instead of a guessing game.
Keep dependencies visible
Dependency changes should be easy to review because they affect install size, startup behavior, and the package tree. Review lockfile and package updates together so you can see what changed and why. That habit makes upgrades much less surprising.
Scripts turn commands into policy
Package scripts give the team a shared entry point for linting, testing, and running the app. That is better than asking everyone to remember a long shell command. When the script names stay stable, the project is easier to hand off.
Next steps
Modules and npm are the foundation of Node.js development. Once you are comfortable with creating and importing modules, the next skill to build is working with the file system — reading, writing, and manipulating files with the built-in fs module. This is covered in the next tutorial in this series.