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 PORT environment variable if it exists; otherwise use 3000.

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 .env configuration 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.