Mert Özen Further With Every Line
Backend Node.js Fundamentals — #02

Installing Node.js and Your First Project: Version Managers, .nvmrc, package.json, node --watch and --env-file

Mert Özen Nov 14, 2026 10 min 9 views
Installing Node.js and Your First Project: Version Managers, .nvmrc, package.json, node --watch and --env-file

The right way to start a Node.js project is to install Node with a version manager, pin the version with .nvmrc and the engines field in package.json, and keep scripts in package.json. With real output: the EBADENGINE error engine-strict gives on Node 24, npm init and the anatomy of package.json, restarts with node --watch, the shell value overriding .env with --env-file, 0.04 versus 0.11 seconds for node --run and npm run, and the Bookstore API's first GET /health endpoint.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS) and npm 11.20.0; the engines test also used Node.js 24.22.3.

Guess First

The Node.js API of Bookstore, the online bookstore used as the shared example across this site's series, runs for the first time in this post. The server reads the port it listens on from an environment variable:

const port = Number(process.env.PORT ?? 3000);

There is a .env file in the project folder:

PORT=3000
NODE_ENV=development

In the terminal you also set PORT to 4000 and start the server with --env-file:

PORT=4000 node --env-file=.env src/server.js

Which port does the server listen on? The .env file exists, as its name says, to load settings; if you think whatever loads last wins, the answer is 3000, and if you think the shell wins, it is 4000.

Bookstore API listening on http://localhost:4000 (development)

The shell won. The Node documentation says so plainly: if the same variable is defined both in the environment and in the file, the value from the environment takes precedence. That is the right choice for production, and it is also the most common answer to "I changed .env, why has nothing changed?" while developing. In this post we install Node with a version manager, start the Bookstore API project with a package.json, and look at the Node counterparts of the dotnet watch, global.json and appsettings habits you know from ASP.NET Core.

Installing Node with a Version Manager

The installer you download from nodejs.org installs a single Node version on the machine. That is enough for one project, but it gets awkward when two projects need different major versions or when you want to try a new LTS before moving to it. In .NET, SDKs install side by side and global.json decides which one is used. In Node a version manager does that job: it keeps several Node versions in your user folder and puts the right one on the PATH depending on the folder.

On Windows the most practical option is fnm (Fast Node Manager). It is written in Rust, ships as a single binary, supports PowerShell and reads both .nvmrc and .node-version files. Installation and first use look like this; I did not run these commands on Windows, they come from fnm's README:

# Windows: install fnm with winget
winget install Schniz.fnm
# Add to your PowerShell profile so it reads .nvmrc when you change folders
fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression
# Install and use the version in .nvmrc
fnm install
fnm use

On macOS and Linux you use fnm or nvm; nvm is a shell script and does not run on Windows. There is a separate project for Windows, nvm-windows, but its commands and behaviour are not the same as nvm's. Whichever you pick, the rule is the same: do not install Node and global tools into a system folder with administrator rights; let a file inside the project decide the version.

One more thing has changed since Node 25: corepack no longer ships with Node. The Node 25.0.0 release notes say corepack was removed from the distribution, and the bin folder of 26.13.1 contains only node, npm and npx. If you want pnpm or Yarn, you need to install corepack separately from npm or install that tool its own way. This series uses npm.

.nvmrc and engines: The Counterpart of global.json

Pinning the version has two layers. The first is .nvmrc: a one-line file that tells version managers which Node to use. setup-node in GitHub Actions can read this file too, so the developer machine and CI use the same version.

26

I wrote only the major version: the version manager picks the newest installed 26, and security releases do not require changing the file. Writing the full version (26.13.1) is more precise but needs an update for every patch; either works, depending on the team's preference.

The second layer is the engines field in package.json. It tells npm which Node range the project runs on. By default npm only warns on a mismatch; if you put a .npmrc in the project root with engine-strict=true, it fails instead:

engine-strict=true

I tried npm install in the same project with Node 24:

npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: bookstore-api@0.1.0
npm error notsup Not compatible with your version of node/npm: bookstore-api@0.1.0
npm error notsup Required: {"node":"^26.13.0"}
npm error notsup Actual:   {"node":"v24.22.3","npm":"11.19.1"}

Note that npm does this check, not Node. On the same machine node src/server.js keeps running on Node 24. If the SDK does not match global.json, the dotnet command does not run at all; in Node the guard only kicks in when installing packages. The real guarantee comes from using the version in .nvmrc in CI.

npm init and the Anatomy of package.json

You can think of package.json as the counterpart of a .csproj in .NET: the project's name, version, dependencies and runnable scripts live here. In an empty folder npm init -y produces a file with the defaults:

Wrote to /home/user/bookstore-api/package.json:

{
  "name": "bookstore-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}

npm 11 now writes "type": "commonjs" explicitly. That line decides which module system loads the project's .js files, and it is the topic of the next post. For the Bookstore API I edited the file like this:

{
  "name": "bookstore-api",
  "version": "0.1.0",
  "private": true,
  "description": "Bookstore API on Node.js",
  "type": "module",
  "main": "src/server.js",
  "engines": {
    "node": "^26.13.0"
  },
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch --env-file-if-exists=.env src/server.js",
    "versions": "node -p process.versions.node"
  }
}

private: true stops the package from being published to the npm registry by accident; application projects should always have it. With "type": "module", .js files load as ES modules, so we use import and export. main is the package's entry point; for an application it is mostly documentation. scripts are the commands you run with npm run: they do the job of dotnet run or the profiles in launchSettings.json. There are no dependencies or devDependencies yet; npm writes them as you add packages, and post 04 covers the details.

The first npm install produces a package-lock.json even without dependencies. We commit it too; post 04 shows why it matters.

The REPL: For Quick Experiments

The node command without arguments opens a REPL. It serves the same purpose as C# Interactive or dotnet-script: seeing what an API returns without opening a file.

Welcome to Node.js v26.13.1.
Type ".help" for more information.
> const price = 349.9
undefined
> new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(price)
'₺349,90'
> [1, 2, 3].map(n => n * price)
[ 349.9, 699.8, 1049.6999999999998 ]
> _
[ 349.9, 699.8, 1049.6999999999998 ]

_ holds the result of the last expression. The 1049.6999999999998 on the last line is also a reminder to be careful with number in money calculations; later posts discuss keeping Bookstore prices as integer amounts of kuruş. You leave the REPL with .exit or by pressing Ctrl+C twice.

The First Endpoint: GET /health

The project skeleton mirrors the layers of the ASP.NET Core Web API series: routes knows HTTP, services the business rules, db the data access. In this post only routes has content; the other folders are waiting:

bookstore-api/
  .env.example
  .gitignore
  .npmrc
  .nvmrc
  package.json
  package-lock.json
  src/
    server.js
    routes/health.js
    services/
    db/
// GET /health: is the process up, and which Node version is it running on?
export function handleHealth(req, res) {
    const body = {
        status: "ok",
        service: "bookstore-api",
        node: process.version,
        uptimeSeconds: Math.round(process.uptime()),
    };
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(body));
}
import { createServer } from "node:http";
import { handleHealth } from "./routes/health.js";

const port = Number(process.env.PORT ?? 3000);

const server = createServer((req, res) => {
    if (req.method === "GET" && req.url === "/health") {
        return handleHealth(req, res);
    }
    res.writeHead(404, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: "not found" }));
});

server.listen(port, () => {
    console.log(`Bookstore API listening on http://localhost:${port} (${process.env.NODE_ENV ?? "development"})`);
});
npm run dev
curl http://localhost:3000/health
{"status":"ok","service":"bookstore-api","node":"v26.13.1","uptimeSeconds":1}

No framework, and routing is two if statements. That is deliberate: we look at the request and response objects of node:http in detail in post 10 and move to Express in post 11. For now the goal is the project itself.

node --watch: The Counterpart of dotnet watch

The dev script starts Node with --watch. In this mode Node watches the entry file and every module it imports, and restarts the process when one of them changes. With the server running I added a field to health.js:

Bookstore API listening on http://localhost:3000 (development)
Change detected in '/home/user/bookstore-api/src/routes/health.js'
Restarting 'src/server.js'
Bookstore API listening on http://localhost:3000 (development)
{"status":"ok","checkedAt":"2026-11-14T17:53:12.917Z","service":"bookstore-api","node":"v26.13.1","uptimeSeconds":1}

uptimeSeconds being 1 again shows the change arrived with a new process. Unlike hot reload in dotnet watch, no state survives here; everything in memory starts over. For an API that is usually fine. --watch has been stable since Node 22; you no longer need a separate package such as nodemon. If you also want to watch files that are not imported, give a folder with --watch-path. --watch is not used in production.

--env-file: The .env File and How It Differs from appsettings

--env-file has not been experimental since Node 24.10. It loads every KEY=value line in the file into process.env; lines starting with # are comments, and quoted and multi-line values are supported. You can pass several --env-file flags, and a later file overrides an earlier one. But as we saw in Guess First, it does not override a variable already defined in the environment. The --env-file-if-exists flag I used in the dev script carries on without an error when the file is missing; --env-file refuses to start the process. If you need to load it from code, there is also process.loadEnvFile().

The difference from ASP.NET Core's configuration system is large. appsettings.json gives you a hierarchical structure, typed Options binding and layering per environment; User Secrets keeps secret values on the developer machine outside the repo. .env is a flat list of strings: no types, no validation, no layers. So we set two rules. First, .env is never committed; a template without values is committed instead:

# Copy to .env; .env is never committed
PORT=3000
NODE_ENV=development
node_modules/
.env
*.log

Second, .env is for development only. In production the values come from the platform: the container environment, Kubernetes secrets or a secret store. In post 14 we turn configuration into a typed object validated against a schema, as the counterpart of the Options pattern.

node --run and npm run

There are two ways to run scripts. npm run has long been the standard; since Node 22 there is also node --run:

> bookstore-api@0.1.0 versions
> node -p process.versions.node

26.13.1
26.13.1

The first two lines and the first 26.13.1 are the output of npm run versions; the last line is from node --run versions. I timed the same script three times: npm run took 0.11–0.12 seconds each time, node --run 0.04 seconds. The difference comes from loading npm itself, which is a Node program. Running node --run without an argument lists the scripts:

Available scripts are:
  start: node src/server.js
  dev: node --watch --env-file-if-exists=.env src/server.js
  versions: node -p process.versions.node

The speed is not free. According to the Node documentation node --run is deliberately limited: it does not run pre and post scripts such as prestart or poststart, and it does not define npm-specific environment variables. If your scripts rely on those, stay with npm run. This project can use either; in the posts I write npm run for readability.

In Production

In production start the process directly with node src/server.js; neither --watch nor --env-file. Starting it with npm start inside a container puts npm between the process and its signals; SIGTERM reaching Node and graceful shutdown are covered in post 14 and in the Docker series.

Define NODE_ENV=production on the platform. Node itself gives this variable no special meaning, but many libraries such as Express change their behaviour based on it. We measure the effect when we move to Express.

In CI, read the Node version from .nvmrc and keep engine-strict on. A project that runs 24 on the developer's machine and 26 in CI fails for the first time in the worst place.

Do not install globally. Every tool the project needs (TypeScript, the test runner, the linter) lives in devDependencies and runs through npx or scripts. That way everyone who clones the project uses the same version.

Common Pitfalls

Depending on global npm packages. A tsc installed with npm install -g typescript works on your machine and is missing, or a different version, in CI. Scripts should only use the tools in the project's own node_modules/.bin; npm run and node --run already add that folder to the PATH.

Committing .env. A value committed once stays in the git history; deleting the file is not enough, you have to rotate the value. Add .gitignore in the project's first commit.

Assuming .env overrides everything. A variable defined in the shell or in CI overrides the file; if you see an unexpected value, check the environment first.

Switching to node --run without noticing that pre and post scripts are silently skipped. If you have a step such as prebuild, either move it into the main script or stay with npm run.

Treating engines as protection. It only stops npm; with the wrong Node the node command keeps running.

A Small Experiment

★☆☆ Create two files called .env.development and .env.local, put different PORT values in them and start with node --env-file=.env.development --env-file=.env.local src/server.js. Which port wins? What happens when you swap the order?

★★☆ Add "prestart": "node -e \"console.log('checking config')\"" to package.json. Compare the output of npm start and node --run start.

★★★ Add the version value from package.json to the /health response. Load it once when the process starts rather than reading the file on every request. Hint: you can use the JSON import attributes from the next post, or node:fs.

The project is up, but we have not discussed why the import lines carry a .js extension or what exactly "type": "module" changes. In the next post we cover Node's two module systems, CommonJS and ES modules, the exports and imports fields, and require(esm), which is now the default.