How to buildOctober 11, 20269 min read
Backend with Node.jsStep 2 of 8

Node.js from scratch: modules, npm and a first server

In short

Node.js is a JavaScript runtime outside the browser: the same language, but instead of the DOM and window you get the file system, the network and operating system processes. A Node.js project starts with npm init and a package.json file, where ES modules are enabled with "type": "module". A first server is written on the built-in node:http module with no dependencies, settings come from environment variables via the --env-file flag, and --watch restarts the process when the code changes.

TutorialLevel: BeginnerNode.jsPractice: ~45–90 min

After this you will be able to

  • create an npm project with ES modules, scripts and a lockfile, and explain the difference between npm install and npm ci
  • split code into modules with import/export and use node:fs/promises, node:path and node:events
  • write an HTTP server on node:http with GET /health and GET /services routes, 404 and 405 codes and a request log
  • read settings from environment variables via --env-file and run the server in --watch mode
Contents
  1. 01What Node.js is and how it differs from the browser
  2. 02An npm project: package.json, dependencies and the lockfile
  3. 03ES modules: import, export and what changed after require
  4. 04Built-in modules: fs, path and events
  5. 05A first HTTP server and manual routing
  6. 06Environment variables, --env-file and --watch mode
  7. 07Exercises, and why a framework comes next

What Node.js is and how it differs from the browser

In the What backend is lesson we looked at what a server does. Now for a tool to write that server without leaving JavaScript. Node.js is an open-source, cross-platform JavaScript runtime. Inside it runs V8, the same engine that executes JavaScript in Chrome, but there is no browser around it: no document, no window, no DOM. Instead there is a standard library for files, networking, processes and data streams.

The Node.js documentation stresses the key trait: an app runs in a single process and does not create a thread per request. I/O operations — reading a file, querying a database, calling the network — are non-blocking: while the answer is pending, Node.js serves other requests and then returns to your code through a promise or callback. It is the same event loop model you saw in the asynchronous JavaScript lesson, applied to a server.

What is available

Browser
DOM, window, fetch, localStorage, cookies
Node.js
fs, http, path, process, fetch, OS streams and processes

JavaScript version

Browser
Whatever the visitor's browser supports
Node.js
Whatever you installed on the server

Modules

Browser
Only import (ES modules)
Node.js
ES modules and CommonJS (require)

OS access

Browser
Isolated in a sandbox
Node.js
Full: files, network, environment variables

An important consequence of the last row: server code sees your keys, database and file system, so everything you read from a request must be validated. For lessons, use the current LTS line, the long-term support release. At the time of checking that is Node.js 24 (codename Krypton); this lesson's code was verified on 24.18. Check your versions:

terminal
$ node -v
v24.18.0
$ npm -v
11.16.0
Node.js ships with npm, the package manager

An npm project: package.json, dependencies and the lockfile

Every Node.js project is a folder with a package.json file: name, version, run scripts and the dependency list. The npm init command creates it, and -y accepts the defaults without questions. After that it is safer to edit the file with npm pkg set rather than by hand, so you cannot break the JSON. Let's create an API project with a list of services:

bash
mkdir services-api
cd services-api
npm init -y
npm pkg set type=module
npm pkg set scripts.dev="node --watch --env-file=.env server.js" scripts.start="node server.js"
npm pkg set private=true --json
npm pkg delete scripts.test main
Creating the project: ES modules, dev/start scripts and protection from accidental publishing
package.json
{
  "name": "services-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "node --watch --env-file=.env server.js",
    "start": "node server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "private": true,
  "dependencies": {
    "zod": "^4.6.6"
  }
}
The resulting package.json — the actual output of the commands above (npm 11.16)
  • "type": "module" makes .js files in the project ES modules. npm 11 writes "type": "commonjs" by default (the init-type setting), so we change it explicitly. The Node.js docs recommend always setting the type field rather than relying on detection
  • "private": true makes npm refuse to publish the project to the registry: for an application, unlike a library, this guards against a mistake
  • scripts are project commands. npm run dev starts the dev server, npm start runs without dev flags. Since Node.js 22 scripts can also be run with the built-in node --run dev

npm install <name> installs packages from the npm registry. Let's install Zod, a validation library we will need in the next lesson on Express:

terminal
$ npm install zod

added 1 package, and audited 2 packages in 2s

1 package is looking for funding
  run `npm fund` for details

found 0 vulnerabilities

$ npm pkg get dependencies
{
  "zod": "^4.6.6"
}

$ npm ls
services-api@1.0.0 …/services-api
`-- zod@4.6.6
Actual output; project path shortened

dependencies in package.json

Why
Packages the app needs at runtime; devDependencies are for development and tests only (npm install -D)
In Git?
Yes

^4.6.6

Why
A version range: any compatible 4.x from 4.6.6 up, but not 5.0
In Git?
—

package-lock.json

Why
The exact tree of installed versions with checksums. The npm docs: the file is meant to be committed so the team, CI and the server install the same thing
In Git?
Yes

node_modules/

Why
The packages themselves. Restored from the lockfile
In Git?
No, in .gitignore

ES modules: import, export and what changed after require

A module is a file with its own scope: only what you explicitly export is visible outside. Node.js supports two module systems. CommonJS with require() is the historical one you will meet in older projects and examples. ES modules with import and export are the language standard, the same as in the browser. New projects use ES modules. Let's move settings and data loading into separate modules:

src/config.js
const port = Number(process.env.PORT ?? 8080);

if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error(`PORT must be an integer from 1 to 65535, got "${process.env.PORT}"`);
}

export const config = {
  port,
  host: process.env.HOST ?? "127.0.0.1",
  appName: process.env.APP_NAME ?? "services-api",
};
Settings module: reads environment variables once and exports a ready object
src/services.js
import { readFile } from "node:fs/promises";
import { join } from "node:path";

const file = join(import.meta.dirname, "..", "data", "services.json");

export async function loadServices() {
  const text = await readFile(file, "utf8");
  return JSON.parse(text);
}
Data module: the file path is built from the module's folder, not the terminal's current folder
data/services.json
[
  { "id": "web", "title": "Сайт для бизнеса", "category": "web" },
  { "id": "crm", "title": "CRM-система", "category": "software" },
  { "id": "bot", "title": "Telegram-бот", "category": "integrations" }
]
  • Built-in modules are imported with the node: prefix — node:fs/promises, node:http. It makes clear this is not an npm package with a similar name
  • Relative imports must include the file extension: ./src/config.js, not ./src/config — the ESM docs require it, just like the browser
  • require, __dirname and __filename are not defined in ES modules. Use import.meta.dirname and import.meta.filename instead, stable since Node.js 24
  • await works at the top level of a module, without an async wrapper — we use it to load data before the server starts
terminal
$ node a.mjs        # import { config } from "./config"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/config' imported from …/a.mjs
Did you mean to import "./config.js"?

$ node b.mjs        # const fs = require("node:fs")
ReferenceError: require is not defined in ES module scope, you can use import instead

$ node c.mjs        # console.log(__dirname)
ReferenceError: __dirname is not defined in ES module scope
Three typical errors when moving from CommonJS — actual Node.js 24.18 messages, paths shortened

Built-in modules: fs, path and events

The Node.js standard library covers most of what a simple server needs, with zero dependencies. Two modules already work in src/services.js: readFile from node:fs/promises reads a file and returns a promise, and join from node:path joins a path with the right separators for Windows and Unix. Use the promise-based fs: synchronous functions like readFileSync stop the whole process until the disk answers and on a server are acceptable at startup at most.

node:fs/promises

What for
Files and folders
Example
await readFile(path, "utf8"), writeFile, mkdir

node:path

What for
Paths without manual string joining
Example
join(import.meta.dirname, "data")

node:http

What for
HTTP server and client
Example
createServer((req, res) => …)

node:events

What for
Subscribing to events
Example
emitter.on("name", handler)

node:process / process

What for
Environment, arguments, exit
Example
process.env.PORT, process.exit(1)

The node:events module is worth understanding right away because half of Node.js is built on it: the HTTP server, request, response and streams are all EventEmitter objects. One object fires an event with emit, and the listeners registered with on are called in order, synchronously. It is a handy way to decouple code: the orders module does not need to know who sends emails or counts statistics.

events-demo.js
import { EventEmitter } from "node:events";

const orders = new EventEmitter();

orders.on("created", (order) => console.log(`email: order #${order.id} received`));
orders.on("created", (order) => console.log(`stats: +${order.total} KZT`));
orders.once("created", () => console.log("first order today!"));

orders.emit("created", { id: 1, total: 12000 });
orders.emit("created", { id: 2, total: 8500 });
console.log("listeners:", orders.listenerCount("created"));

orders.emit("error", new Error("payment service timeout"));
console.log("this line is never printed");
EventEmitter: two permanent listeners, one one-time (once) listener and an error event without a handler
terminal
$ node events-demo.js
email: order #1 received
stats: +12000 KZT
first order today!
email: order #2 received
stats: +8500 KZT
listeners: 2
…/events-demo.js:13
orders.emit("error", new Error("payment service timeout"));
                     ^

Error: payment service timeout
    at …/events-demo.js:13:22
Emitted 'error' event at:
    at …/events-demo.js:13:8

Node.js v24.18.0
Actual output: once fired a single time, and an error event with no listener ended the process

A first HTTP server and manual routing

We covered requests, responses, headers and status codes in detail in the HTTP and JSON lesson, which already had an orders server on node:http. Here the server is assembled from the project's modules and gets two things no backend can do without: a request log and handling of startup errors. There are two routes: GET /health checks that the process is alive (hosting platforms and load balancers poll it), and GET /services returns the list of services from the file.

server.js
import { createServer } from "node:http";
import { config } from "./src/config.js";
import { loadServices } from "./src/services.js";

const services = await loadServices();

function sendJson(res, status, data, headers = {}) {
  const body = JSON.stringify(data);
  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Content-Length": Buffer.byteLength(body),
    ...headers,
  });
  res.end(body);
}

const routes = {
  "/health": () => ({ status: "ok", uptime: Math.round(process.uptime()) }),
  "/services": () => services,
};

const server = createServer((req, res) => {
  const started = performance.now();
  res.on("finish", () => {
    const ms = (performance.now() - started).toFixed(1);
    console.log(`${req.method} ${req.url} ${res.statusCode} ${ms}ms`);
  });

  const { pathname } = new URL(req.url, "http://localhost");
  const route = routes[pathname];
  if (!route) return sendJson(res, 404, { error: "Not found" });
  if (req.method !== "GET") {
    return sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
  }
  sendJson(res, 200, route());
});

server.on("error", (error) => {
  console.error(`Cannot start server: ${error.code} ${error.message}`);
  process.exit(1);
});

server.listen(config.port, config.host, () => {
  console.log(`${config.appName}: http://${config.host}:${config.port}`);
});
A dependency-free server: a route table, 404 and 405, a log via the finish event, startup error handling
  1. Data loads before startup

    Top-level await loadServices(): if the file is broken, the server does not start at all — better than serving errors.

  2. Routes are an object

    The key is the path, the value is a function returning data. No key — 404. The path exists but the method is not GET — 405 with an Allow header, as RFC 9110 requires.

  3. Logging via an event

    res.on("finish") fires when the response has been handed to the operating system. At that point the status code and duration are known — one log line per request.

  4. Startup errors

    server.on("error") catches, for example, EADDRINUSE — the port is taken — and exits with a clear message and code 1.

Run npm run dev and send requests from a second terminal:

terminal
$ curl -si http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 26
Date: Sun, 11 Oct 2026 23:13:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"status":"ok","uptime":1}

$ curl -s http://127.0.0.1:8080/services
[{"id":"web","title":"Сайт для бизнеса","category":"web"},{"id":"crm","title":"CRM-система","category":"software"},{"id":"bot","title":"Telegram-бот","category":"integrations"}]

$ curl -si -X POST http://127.0.0.1:8080/services
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Content-Length: 30
Allow: GET
Date: Sun, 11 Oct 2026 23:13:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"Method not allowed"}

$ curl -s http://127.0.0.1:8080/price
{"error":"Not found"}
Actual responses: 200, 405 with an Allow header, and 404

Environment variables, --env-file and --watch mode

The port, database URL and service keys differ between your laptop, staging and production, so they are not written into code but read from environment variables, the process.env object. During development it is convenient to keep them in a .env file, and Node.js can load it itself, without the dotenv package: the --env-file flag has been stable since version 24.10.

.env
PORT=8080
HOST=127.0.0.1
APP_NAME=InterLAB services API
Local settings — they never reach Git
.gitignore
node_modules/
.env
The repository gets a secret-free copy, .env.example, so teammates know which variables are needed
terminal
$ node server.js
services-api: http://127.0.0.1:8080

$ PORT=8081 node --env-file=.env server.js
InterLAB services API: http://127.0.0.1:8081

$ PORT=abc node server.js
Error: PORT must be an integer from 1 to 65535, got "abc"
    at …/src/config.js:4:9

$ node --env-file=.env.local server.js
C:\Program Files\nodejs\node.exe: .env.local: not found

$ node --env-file=.env server.js   # second instance, port already in use
Cannot start server: EADDRINUSE listen EADDRINUSE: address already in use 127.0.0.1:8080
Actual runs (output and paths shortened): without the file, with environment precedence, with an invalid PORT, with a missing .env.local, and with a busy port
  • All environment values are strings. PORT=8080 arrives as "8080", so config.js converts it to a number and checks the range. A config error is better caught at startup than on the first request
  • If a variable is set both in the environment and in the file, the environment wins — so the server can override a value from .env
  • --env-file exits if the file is missing. For an optional file there is --env-file-if-exists, and from code, process.loadEnvFile()
  • In production, secrets are set through the hosting platform or a secrets manager, not a file next to the code. More in the lesson on secrets and environment variables

The --watch flag restarts the process when the entry point or any imported module changes — in the log above that is the Restarting 'server.js' line. Testing on Node.js 24.18 showed that a restart also happens when the --env-file file changes, but not when you edit data/services.json: that file is read with fs, not imported. To watch arbitrary folders use --watch-path=.; note that this flag turns off automatic tracking of imports, so list whole folders.

terminal
$ npm run dev

> services-api@1.0.0 dev
> node --watch --env-file=.env server.js

InterLAB services API: http://127.0.0.1:8080
GET /health 200 2.4ms
GET /services 200 0.3ms
POST /services 405 0.3ms
GET /price 404 0.3ms
Restarting 'server.js'
InterLAB services API: http://127.0.0.1:8080
GET /services 200 2.7ms
The npm run dev log: four requests, then an edit to src/services.js and an automatic restart

Exercises, and why a framework comes next

The server works, but look how much was done by hand: comparing paths, pulling parameters out with a regular expression, building the JSON response, remembering 405. POST adds reading the body, parsing JSON, checking Content-Type, validation and a single error format — in the HTTP and JSON lesson that took dozens of lines per route. This is exactly what a framework takes over. In the next lesson we move the API to Express: routes with parameters, middleware, validation with Zod and a central error handler.

  • package.json has "type": "module" and "private": true; the lockfile is in Git, node_modules is not
  • Built-in modules are imported with node:, relative imports include the .js extension
  • Files are read with node:fs/promises, paths are built with path.join and import.meta.dirname
  • Settings are read from process.env, converted to the right type and validated at startup
  • .env is in .gitignore, with a secret-free .env.example next to it
  • Development uses node --watch --env-file=.env, production a plain node server.js
How this was verified9 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.