When I first created a Node.js project, I mostly thought:
npm init
creates a package.json, and then I install packages with:
npm install express
Done.
But package.json is actually much more important than that.
It describes the project, manages dependencies, defines commands, and can even control how the application is started.
Then there are terms like:
- SemVer
- dependencies
- devDependencies
- npm scripts
- environment variables
- dotenv
- configuration management
- monorepos
At first, these felt like separate topics.
But they are all part of one bigger question:
How do we keep a Node.js project organized, reproducible, and configurable as it grows?
1. What Is package.json?
package.json is basically the identity card and configuration file of a Node.js project.
A simple example:
{
"name": "my-node-app",
"version": "1.0.0",
"description": "A simple Node.js application",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^5.0.0"
}
}
It tells npm things like:
- What is this project called?
- What version is it?
- What packages does it need?
- What commands can I run?
- What is the entry point?
- What kind of project is this?
2. Why Does package.json Matter?
Imagine giving your project to another developer.
You don't want to say:
"Install Express, install Jest, install this package, then run this command..."
Instead, they should be able to clone the project and run:
npm install
npm reads:
package.json
and knows what packages the project needs.
Then they can run:
npm start
or:
npm test
depending on the scripts defined by the project.
So package.json helps make a project repeatable.
3. Creating package.json
The usual command is:
npm init
npm asks questions such as:
package name
version
description
entry point
test command
git repository
keywords
author
license
You can also skip the interactive questions:
npm init -y
This creates a default package.json.
4. Understanding the Main Fields
A common package.json might look like:
{
"name": "node-api",
"version": "1.0.0",
"description": "A Node.js REST API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
Let's break this down.
5. name
"name": "node-api"
This is the name of the package/project.
For a publishable npm package, naming rules matter.
For a normal application, it mainly identifies the project.
6. version
"version": "1.0.0"
This represents the current version of the project/package.
This leads us to an important concept:
Semantic Versioning
or simply:
SemVer
7. SemVer: Semantic Versioning
Semantic Versioning usually looks like:
MAJOR.MINOR.PATCH
For example:
2.4.7
means:
MAJOR = 2
MINOR = 4
PATCH = 7
The basic idea is:
2.4.7
│ │ │
│ │ └── PATCH
│ └──── MINOR
└────── MAJOR
8. MAJOR Version
The major version generally changes when there are breaking changes.
For example:
1.5.2
becomes:
2.0.0
Imagine a library had:
getUser(id)
and in version 2 it changes to:
getUser({ id })
Existing code may stop working.
That's the kind of change that can justify a major version increase.
Think:
MAJOR = "Something may break."
9. MINOR Version
A minor version generally adds functionality while maintaining backward compatibility.
For example:
1.5.2
becomes:
1.6.0
Maybe a new feature is added:
getUser()
getUsers()
searchUsers()
Existing code should continue working.
Think:
MINOR = "New functionality, but existing usage should continue working."
10. PATCH Version
A patch version is generally for backward-compatible fixes.
For example:
1.5.2
becomes:
1.5.3
Maybe a bug is fixed.
Think:
PATCH = "Bug fix."
11. The Easy SemVer Memory Trick
Remember:
MAJOR → Breaking changes
MINOR → New features
PATCH → Bug fixes
So:
1.4.2
can become:
2.0.0 → breaking change
1.5.0 → new feature
1.4.3 → bug fix
This is the basic idea behind SemVer.
12. What Does ^ Mean?
You may have seen this:
"express": "^5.0.0"
The ^ is a version range operator.
It tells npm that compatible updates within the allowed SemVer range can be installed.
For a dependency already using:
^5.0.0
the range generally allows versions:
>=5.0.0 <6.0.0
So npm can install a newer compatible release such as:
5.1.0
or:
5.2.3
but not:
6.0.0
because that crosses the major version boundary.
13. What Does ~ Mean?
You may also see:
"express": "~5.0.0"
The ~ is usually more restrictive.
It generally allows patch-level updates within the same minor version:
>=5.0.0 <5.1.0
So versions such as:
5.0.1
5.0.2
5.0.9
can fit the range, while:
5.1.0
does not.
14. Exact Versions
You can also specify:
"express": "5.0.0"
This requests exactly that version.
No SemVer range is being specified.
In practice, the lockfile also plays an important role in ensuring reproducible installations.
15. dependencies vs devDependencies
This is one of the most common Node.js questions.
What's the difference between:
"dependencies": {}
and:
"devDependencies": {}
The simple answer is:
dependencies are needed by the application.
devDependencies are mainly needed while developing, testing, building, or maintaining the application.
16. Example of a Dependency
Suppose we're building an Express API.
We write:
const express = require("express");
Our application needs Express to actually run.
So:
npm install express
will put Express under:
"dependencies"
For example:
"dependencies": {
"express": "^5.0.0"
}
17. Example of a Dev Dependency
Suppose we use ESLint:
npm install --save-dev eslint
ESLint helps us check our code.
But our production API doesn't need ESLint running to process:
GET /users
It's a development tool.
So it belongs in:
"devDependencies"
Example:
"devDependencies": {
"eslint": "^9.0.0"
}
18. More Examples
Usually dependencies
express
nestjs
mongoose
pg
drizzle-orm
jsonwebtoken
These are packages your application may need while running.
Usually devDependencies
jest
eslint
prettier
typescript
nodemon
testing tools
build tools
These are usually tools used during development, testing, or building.
The exact classification can depend on the project's architecture.
19. A Simple Question to Decide
When you're unsure where a package belongs, ask:
"Does my application need this package to actually run in its target environment?"
If yes:
dependencies
If it's primarily there to help you develop, test, lint, format, or build:
devDependencies
20. What Is package-lock.json?
When you install packages, npm usually creates:
package-lock.json
This file records the exact dependency tree that npm resolved.
For example, package.json might say:
"express": "^5.0.0"
That allows a range of versions.
But the lockfile records the specific versions selected during installation, along with their dependency information.
This helps different developers and CI environments install a consistent dependency tree.
So a useful mental model is:
package.json
→ What versions/ranges does the project allow?
package-lock.json
→ What exact dependency tree was resolved?
For npm projects, the lockfile is normally committed to Git.
21. npm Scripts
Now let's talk about another extremely useful part of package.json:
"scripts": {}
Scripts are shortcuts for commands we repeatedly run.
For example:
"scripts": {
"start": "node src/index.js",
"test": "node --test"
}
Now instead of typing:
node src/index.js
we can type:
npm start
And:
npm test
runs the test command.
22. Why Are npm Scripts Useful?
Imagine a project has these commands:
node src/index.js
npm run lint
npm test
npm run build
Instead of asking every developer to memorize them, we put them in:
"scripts"
Then the project becomes self-documenting.
For example:
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test",
"lint": "eslint ."
}
Now I know:
npm run dev
npm start
npm test
npm run lint
23. npm run
For most custom scripts, use:
npm run <script>
For example:
npm run dev
npm run lint
npm run build
Some scripts have special npm shortcuts.
For example:
npm start
npm test
are commonly used directly.
24. A Realistic scripts Section
A Node.js project might have:
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test",
"lint": "eslint .",
"format": "prettier --write ."
}
This gives the project a predictable interface.
As a developer, I don't need to remember exactly how every command works internally.
I just run the project's scripts.
25. Configuration Management
Now let's imagine our application needs:
PORT
DATABASE_URL
JWT_SECRET
API_KEY
NODE_ENV
We could hard-code them:
const port = 3000;
const databaseUrl = "postgres://...";
But this is a bad idea for many real applications.
Why?
Because different environments need different values.
For example:
Development
PORT=3000
Testing
PORT=4000
Production
PORT=8080
The same code should ideally work in all three environments.
Only the configuration should change.
26. Environment Variables
Environment variables let us keep configuration outside the source code.
For example:
PORT=3000
NODE_ENV=development
DATABASE_URL=postgres://localhost/mydb
Then Node.js can access them using:
process.env.PORT
For example:
const port = Number(process.env.PORT || 3000);
This means:
Use the
PORTenvironment variable if it exists; otherwise use3000.
27. Why Not Put Secrets in Git?
Imagine this:
const JWT_SECRET = "super-secret-password";
and then we push the code to GitHub.
Now the secret may be exposed.
Instead, we can use:
JWT_SECRET=some-secret-value
and read it through:
process.env.JWT_SECRET
The actual secret should be provided through the deployment environment or a secret-management system.
28. What Is dotenv?
Node.js can read environment variables supplied by the operating system through:
process.env
But during local development, it is convenient to store variables in a .env file.
For example:
PORT=3000
NODE_ENV=development
DATABASE_URL=postgres://localhost/social_feed_dev
A popular package called dotenv can load these values into process.env.
Install it:
npm install dotenv
Then:
require("dotenv").config();
Now:
console.log(process.env.PORT);
can access the value from .env.
29. dotenv/config
There is also a convenient way to load dotenv automatically.
For CommonJS:
require("dotenv").config();
For some Node.js setups, you may see:
node -r dotenv/config src/index.js
The idea is:
Load the
.envconfiguration before the application starts.
The exact approach depends on how the project is structured.
30. .env Should Usually Not Be Committed
A typical project might have:
.env
.env.example
.env contains actual local values:
DATABASE_URL=real-local-value
JWT_SECRET=real-secret
.env.example contains the expected variable names without real secrets:
PORT=
NODE_ENV=
DATABASE_URL=
JWT_SECRET=
Then .gitignore should normally contain:
.env
This gives other developers a template without exposing your secrets.
31. Configuration Should Be Centralized
Instead of scattering:
process.env.PORT
process.env.DATABASE_URL
process.env.JWT_SECRET
throughout the application, we can create a configuration layer.
For example:
const config = {
port: Number(process.env.PORT || 3000),
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET
};
module.exports = config;
Then other parts of the application can use:
config.port
instead of directly reading environment variables everywhere.
This creates a clean boundary:
Environment
↓
Configuration
↓
Application
32. Configuration Validation
There's another important improvement.
Suppose:
DATABASE_URL
is missing.
Our application might start and fail much later with a confusing database error.
Instead, we can validate configuration at startup.
Conceptually:
Application starts
↓
Load configuration
↓
Validate required values
↓
Invalid?
├── Yes → Fail fast
└── No → Start application
For example:
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is required");
}
In larger applications, configuration libraries can make this cleaner.
33. Development vs Production Configuration
The same application may run in:
Development
Testing
Staging
Production
For example:
NODE_ENV=development
or:
NODE_ENV=production
Configuration may differ between these environments.
But the application code should ideally remain the same.
Think:
Same application
+
Different configuration
=
Different environment
34. What Is a Monorepo?
Now let's move to a bigger project concept.
A monorepo means keeping multiple related projects/packages in one Git repository.
For example:
company-project/
│
├── apps/
│ ├── web/
│ ├── api/
│ └── admin/
│
├── packages/
│ ├── ui/
│ ├── config/
│ └── shared/
│
└── package.json
Instead of having:
frontend-repo
backend-repo
shared-repo
we keep them together.
35. Why Use a Monorepo?
Imagine a company has:
React Web App
Node API
Admin Dashboard
Shared UI Components
Shared TypeScript Types
These projects may depend on each other.
A monorepo makes it easier to manage them together.
For example:
apps/web
↓
packages/ui
apps/api
↓
packages/types
The shared packages live in the same repository.
36. Monorepo vs Polyrepo
Polyrepo
Separate repositories:
frontend-repo
backend-repo
mobile-repo
shared-types-repo
Monorepo
One repository:
company-repo
├── apps
│ ├── frontend
│ ├── backend
│ └── mobile
└── packages
└── shared
Neither approach is automatically better.
The right choice depends on the team's needs, deployment strategy, tooling, and project structure.
37. npm Workspaces
npm itself supports a basic monorepo mechanism called workspaces.
A root package.json might contain:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
Then the repository can contain:
apps/
api/
web/
packages/
shared/
ui/
Each workspace can have its own:
package.json
while npm manages the overall workspace.
38. Why private: true?
You will often see:
"private": true
in the root package of a monorepo.
It tells npm that this package is not intended to be published as a normal npm package.
The root is mainly being used to manage the workspace.
39. Monorepo Mental Model
Think of a monorepo as a company building.
Company Building
|
┌────────────┼────────────┐
| | |
Web App API Admin App
| | |
└────────────┼────────────┘
|
Shared Packages
Everything is in one repository, but each application/package can still have its own responsibility.
40. Do I Need a Monorepo as a Beginner?
Probably not.
If you're building:
one Node.js API
a normal repository is perfectly fine:
my-api/
├── src/
├── test/
├── package.json
├── package-lock.json
└── .env.example
Don't introduce monorepo tooling just because it sounds advanced.
First understand:
- npm
- package.json
- dependencies
- scripts
- configuration
- Git
- testing
- application structure
Then learn monorepos when your project actually has multiple related applications/packages.
41. Putting Everything Together
A realistic Node.js project might look like:
my-api/
│
├── src/
│ ├── config/
│ │ └── index.js
│ ├── routes/
│ ├── services/
│ └── index.js
│
├── test/
│
├── .env
├── .env.example
├── .gitignore
├── package.json
├── package-lock.json
└── README.md
The package.json might look like:
{
"name": "my-api",
"version": "1.0.0",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"dotenv": "^17.0.0",
"express": "^5.0.0"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
And .env.example:
PORT=3000
NODE_ENV=development
DATABASE_URL=
JWT_SECRET=
Now the project has:
package.json
↓
Project metadata + dependencies + scripts
package-lock.json
↓
Resolved dependency tree
.env
↓
Local configuration
dotenv
↓
Loads local environment variables
npm scripts
↓
Standard project commands
42. A Simple Mental Model
If I had to remember the whole topic in one diagram:
Node.js Project
|
┌─────────────┼─────────────┐
| | |
package.json .env package-lock
| | |
┌────┴────┐ | Exact dependency
| | | tree
dependencies scripts |
| | |
Runtime Commands Configuration
packages |
↓
process.env
And when the project becomes large:
Monorepo
|
┌─────────┴─────────┐
| |
apps/ packages/
| |
web / api shared / ui
43. Quick Revision Cheat Sheet
| Concept | Simple Meaning |
|---|---|
package.json |
Project metadata, dependencies, scripts, and configuration |
version |
Current package/project version |
| SemVer | Versioning convention: MAJOR.MINOR.PATCH |
| MAJOR | Breaking changes |
| MINOR | Backward-compatible features |
| PATCH | Backward-compatible fixes |
^ |
Allows compatible updates within a major version |
~ |
Usually allows patch updates within a minor version |
dependencies |
Packages needed by the application |
devDependencies |
Packages mainly needed for development/testing/building |
package-lock.json |
Records the resolved dependency tree |
| npm scripts | Named commands defined in package.json
|
| Environment variable | Configuration supplied outside the code |
process.env |
Accesses environment variables in Node.js |
| dotenv | Loads variables from .env into process.env
|
.env |
Local configuration/secrets; normally not committed |
.env.example |
Template showing required variables |
| Configuration layer | Central place for application configuration |
| Monorepo | Multiple related projects/packages in one repository |
| npm workspaces | npm's built-in workspace/monorepo mechanism |
44. Final Takeaway
The biggest lesson for me is that a Node.js project isn't just:
JavaScript files
There is an entire ecosystem around the application.
package.json
↓
Defines the project
↓
Dependencies + scripts
↓
Application runs
↓
Configuration comes from environment
↓
dotenv can load local .env values
↓
package-lock keeps installations reproducible
And when the project grows:
One project
↓
Multiple applications/packages
↓
Monorepo
The important thing isn't memorizing every npm command.
It's understanding why these files and concepts exist.
Once that is clear, commands like:
npm install
npm install --save-dev eslint
npm run dev
npm test
npm start
stop feeling like random commands.
They become tools for managing a real software project.
And that's the point where Node.js development starts feeling much more organized.
Top comments (0)