Node.js Fundamentals
What is Node.js
Node.js is a JavaScript runtime built on Chrome's V8 engine. It lets you run JavaScript outside the browser — on servers, in CLI tools, in build pipelines. Unlike browser JS, Node.js has access to the file system, network sockets, operating system APIs, and child processes.
Key characteristics:
- Single-threaded event loop — one thread handles all requests. I/O operations (file reads, network calls, database queries) are non-blocking: Node starts the operation, continues processing other requests, and runs a callback when the result is ready. This makes it efficient for I/O-heavy workloads (web servers, APIs) but unsuitable for CPU-heavy computation (image processing, machine learning).
- V8 engine — same JavaScript engine as Chrome. Compiles JS to machine code at runtime (JIT compilation).
- npm ecosystem — the largest package registry in any language.
# Check your Node.js version (20+ required)
node --version
# Run a script
node script.js
# Start a REPL (Read-Eval-Print Loop)
node
Node.js 20+ is required — we use import.meta.dirname (available since Node 20.11) and Express 5. Use the latest LTS version (even-numbered: 20, 22).
CommonJS vs ES Modules
Node.js supports two module systems. You need to know both — many npm packages still use CommonJS, while modern code uses ES modules.
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| File extension | .js (default) or .cjs | .mjs or .js (with "type": "module") |
Top-level await | No | Yes |
__dirname | Available | Use import.meta.dirname (Node 20.11+) |
this at top level | module.exports | undefined |
CommonJS
// math.js — CommonJS
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js
const { add } = require("./math");
console.log(add(2, 3)); // 5
ES Modules
To use ESM, add "type": "module" to package.json:
{
"type": "module"
}
// math.ts — ES Module
export function add(a: number, b: number): number {
return a + b;
}
// app.ts
import { add } from "./math.js"; // Note: .js extension even for .ts files
console.log(add(2, 3)); // 5
Always use .js extensions in imports when using ES modules with TypeScript (e.g., import { add } from "./math.js"), even though the source file is .ts. TypeScript does not rewrite import paths — the compiled output expects .js files at runtime. Omitting the extension or using .ts causes ERR_MODULE_NOT_FOUND at runtime.
We use ES modules throughout this course. Your tsconfig.json should target "module": "NodeNext" and your package.json should include "type": "module".
File System
Node.js provides the fs module for file system operations. Use the promise-based API (fs/promises) with async/await:
import fs from "fs/promises";
// Read a file
const content = await fs.readFile("data.json", "utf-8");
const data = JSON.parse(content);
// Write a file
await fs.writeFile("output.json", JSON.stringify(data, null, 2));
// Check if a file exists
try {
await fs.access("config.json");
console.log("File exists");
} catch {
console.log("File does not exist");
}
// Read directory contents
const files = await fs.readdir("./src");
console.log(files); // ["index.ts", "app.ts", ...]
// Create a directory (recursive: true creates parent dirs)
await fs.mkdir("dist/db", { recursive: true });
// Delete a file
await fs.unlink("temp.txt");
The synchronous API (fs.readFileSync, etc.) is available for scripts and initialization code where blocking is acceptable:
import fs from "fs";
// Synchronous — blocks the event loop until done
const content = fs.readFileSync("config.json", "utf-8");
Never use synchronous fs operations in request handlers. fs.readFileSync blocks the entire event loop — while one request reads a file, all other requests wait. Use fs/promises with await in any code that runs during request processing. Synchronous reads are fine during server startup (e.g., reading config or migration files before app.listen()).
Path Module
The path module handles file paths correctly across operating systems (forward slashes on Linux/Mac, backslashes on Windows):
import path from "path";
// Join path segments
path.join("src", "db", "migrations"); // "src/db/migrations"
// Resolve to absolute path
path.resolve("src", "index.ts"); // "/Users/you/project/src/index.ts"
// Get parts of a path
path.dirname("/src/db/database.ts"); // "/src/db"
path.basename("/src/db/database.ts"); // "database.ts"
path.extname("/src/db/database.ts"); // ".ts"
__dirname in ES Modules
In CommonJS, __dirname gives the directory of the current file. In ES modules, it does not exist. Use import.meta.dirname (Node.js 20.11+) or the import.meta.url fallback:
// Node.js 20.11+ (recommended)
const migrationsDir = path.join(import.meta.dirname, "migrations");
// Fallback for older Node.js versions
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
Environment Variables
Environment variables configure your application without hardcoding values. They are read from process.env:
const port = process.env.PORT || 3000;
const dbPath = process.env.DB_PATH || "app.db";
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error("JWT_SECRET environment variable is required");
}
.env files with dotenv
In development, store variables in a .env file. The dotenv package loads them into process.env:
npm install dotenv
# .env — never commit this file to git
PORT=3000
DB_PATH=task-api.db
JWT_SECRET=your-secret-key-at-least-32-chars-long
CORS_ORIGIN=http://localhost:5173
// In your entry point file (e.g., index.ts), use the side-effect import:
import "dotenv/config"; // loads .env into process.env before other modules
console.log(process.env.PORT); // "3000"
In ESM, import declarations are hoisted — all imported modules are evaluated before the importing module's body runs. import "dotenv/config" works because it is a side-effect import that executes dotenv.config() during module evaluation. Writing dotenv.config() in the module body would be too late — other imported modules that read process.env would already be loaded with missing values.
Never commit .env to git. Add it to .gitignore. Leaked secrets (database passwords, JWT keys, API tokens) in git history are a common security incident. Commit a .env.example instead with placeholder values to document which variables are needed.
Starter .gitignore:
node_modules
dist
*.db
.env
Environment in production
In production, environment variables are set by the deployment platform — Docker Compose, Kubernetes, CI/CD pipelines — not by .env files:
# docker-compose.yml
services:
api:
environment:
- JWT_SECRET=${JWT_SECRET}
- DB_PATH=/data/app.db
# Or passed directly
JWT_SECRET=prod-secret PORT=8080 node dist/index.js
npm Scripts
The scripts field in package.json defines commands for your project:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest",
"test:run": "vitest run",
"lint": "tsc --noEmit"
}
}
Run them with npm run:
npm run dev # start dev server with hot reload
npm run build # compile TypeScript to JavaScript
npm run start # run compiled output (production)
npm test # shorthand for "npm run test"
npm run lint # type-check without emitting files
npm test and npm start are special — they don't need run. All other scripts require npm run <name>.