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.
After this you will be able to
- create an npm project with ES modules, scripts and a lockfile, and explain the difference between
npm installandnpm ci - split code into modules with
import/exportand usenode:fs/promises,node:pathandnode:events - write an HTTP server on
node:httpwithGET /healthandGET /servicesroutes, 404 and 405 codes and a request log - read settings from environment variables via
--env-fileand run the server in--watchmode
Know beforehand
Verified with
Node.js 24 LTS (24.18)npm 11.16curl 8.21
Checked against docs: October 11, 2026
Contents
- What Node.js is and how it differs from the browser
- An npm project: package.json, dependencies and the lockfile
- ES modules: import, export and what changed after require
- Built-in modules: fs, path and events
- A first HTTP server and manual routing
- Environment variables, --env-file and --watch mode
- Exercises, 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:
$ node -v
v24.18.0
$ npm -v
11.16.0An 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:
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{
"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"
}
}"type": "module"makes.jsfiles in the project ES modules. npm 11 writes"type": "commonjs"by default (theinit-typesetting), so we change it explicitly. The Node.js docs recommend always setting thetypefield rather than relying on detection"private": truemakes npm refuse to publish the project to the registry: for an application, unlike a library, this guards against a mistakescriptsare project commands.npm run devstarts the dev server,npm startruns without dev flags. Since Node.js 22 scripts can also be run with the built-innode --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:
$ 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.6dependencies in package.json
- Why
- Packages the app needs at runtime;
devDependenciesare 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:
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",
};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);
}[
{ "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,__dirnameand__filenameare not defined in ES modules. Useimport.meta.dirnameandimport.meta.filenameinstead, stable since Node.js 24awaitworks at the top level of a module, without an async wrapper — we use it to load data before the server starts
$ 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 scopeBuilt-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.
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");$ 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.0A 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.
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}`);
});Data loads before startup
Top-level
await loadServices(): if the file is broken, the server does not start at all — better than serving errors.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
Allowheader, as RFC 9110 requires.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.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:
$ 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"}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.
PORT=8080
HOST=127.0.0.1
APP_NAME=InterLAB services APInode_modules/
.env$ 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- All environment values are strings.
PORT=8080arrives as"8080", soconfig.jsconverts 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-fileexits 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.
$ 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.7msExercises, 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.jsonhas"type": "module"and"private": true; the lockfile is in Git,node_modulesis not- Built-in modules are imported with
node:, relative imports include the.jsextension - Files are read with
node:fs/promises, paths are built withpath.joinandimport.meta.dirname - Settings are read from
process.env, converted to the right type and validated at startup .envis in.gitignore, with a secret-free.env.examplenext to it- Development uses
node --watch --env-file=.env, production a plainnode 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.