Node.js โ Complete Beginner Tutorial
6 modules ยท 15 examples ยท Click โถ Run on any example to see the output
๐ก How to use this tutorial
Read each explanation, study the code, then click โถ Run to see the output.
Node.js Basics & Modules
Node.js runs JavaScript outside the browser using Google's V8 engine. Unlike browser JavaScript, Node has access to the file system, network, and operating system. Node organises code into modules โ each file is a module. You use require() to import built-in modules (like fs and path) or your own files. Node 12+ also supports ES module syntax (import/export) with the .mjs extension or "type": "module" in package.json.
Hello Node.js โ Running Your First Script
A Node.js script is just a .js file you run with the node command. Node gives you global objects like console, process, and __dirname that are not available in the browser. The process object gives you access to command-line arguments, environment variables, and the ability to exit the process.
// hello.js
console.log('Hello from Node.js!');
console.log('Node version:', process.version);
console.log('Platform:', process.platform);
console.log('Working dir:', process.cwd());
// Command-line arguments (node hello.js Alice 25)
const args = process.argv.slice(2); // slice off 'node' and script path
if (args.length > 0) {
console.log('Arguments:', args.join(', '));
} else {
console.log('No arguments provided.');
}
// Environment variables
const env = process.env.NODE_ENV ?? 'development';
console.log('Environment:', env);
// Timing
console.time('operation');
let sum = 0;
for (let i = 0; i < 1_000_000; i++) sum += i;
console.timeEnd('operation');
console.log('Sum:', sum);process.argv is an array. The first element is the path to node, the second is the path to your script. Your actual arguments start at index 2, which is why we use .slice(2). Run node hello.js Alice 25 and args will be ["Alice", "25"].
CommonJS Modules โ require() and module.exports
Node's original module system is CommonJS. Every file is a module with its own scope โ variables are not global by default. You export values with module.exports and import them with require(). You can export a single value, a function, or an object with multiple named exports.
// math.js โ a simple module
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
const PI = 3.14159;
// Export multiple things as an object
module.exports = { add, subtract, multiply, divide, PI };
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// main.js โ using the module
const math = require('./math');
console.log('add(5, 3):', math.add(5, 3));
console.log('subtract(10, 4):', math.subtract(10, 4));
console.log('multiply(6, 7):', math.multiply(6, 7));
console.log('divide(15, 3):', math.divide(15, 3));
console.log('PI:', math.PI);
// Destructuring import
const { add, PI } = require('./math');
console.log('Direct add(2, 2):', add(2, 2));
console.log('Direct PI:', PI);
try {
math.divide(10, 0);
} catch (err) {
console.log('Error caught:', err.message);
}CommonJS modules are synchronous and cached. The first time you require() a module, Node executes it and caches the result. Every subsequent require() of the same file returns the cached export โ the module code does not run again. This is important to understand when a module has side effects.
Built-in Modules โ os, path, and util
Node ships with many built-in modules you can require() without installing anything. The most useful ones are os (operating system info), path (file path manipulation), url (URL parsing), and util (utilities). These are the tools you reach for constantly when writing Node scripts and servers.
const os = require('os');
const path = require('path');
const util = require('util');
// os โ operating system information
console.log('=== OS Info ===');
console.log('Hostname:', os.hostname());
console.log('Platform:', os.platform());
console.log('Arch:', os.arch());
console.log('CPUs:', os.cpus().length);
console.log('Free mem (MB):', Math.round(os.freemem() / 1024 / 1024));
console.log('Total mem (MB):', Math.round(os.totalmem() / 1024 / 1024));
console.log('Home dir:', os.homedir());
// path โ cross-platform file paths
console.log('\n=== Path Utils ===');
const filePath = '/home/alice/projects/app/server.js';
console.log('dirname:', path.dirname(filePath));
console.log('basename:', path.basename(filePath));
console.log('extname:', path.extname(filePath));
console.log('no ext:', path.basename(filePath, '.js'));
// Building paths safely (uses / or \ per OS)
const newPath = path.join('/home', 'alice', 'projects', 'index.js');
console.log('join:', newPath);
// Resolve absolute path
const abs = path.resolve('src', 'index.js');
console.log('resolve:', abs);
// util โ helpful utilities
console.log('\n=== Util ===');
const formatted = util.format('Name: %s, Age: %d, Pass: %s', 'Alice', 25, true);
console.log(formatted);Always use path.join() and path.resolve() instead of string concatenation for file paths. Concatenating strings gives you the wrong separator on Windows (/ vs \). path.join() handles this automatically, making your code work correctly on every operating system.
Async JavaScript
Node.js is single-threaded but handles many operations concurrently through its event loop. Operations like file I/O, network requests, and database queries are asynchronous โ they start and then call back when done, freeing the thread to handle other things in the meantime. JavaScript has evolved three ways to handle async: callbacks (original), Promises (ES6), and async/await (ES2017). Modern Node code uses async/await almost exclusively.
Promises
A Promise represents a value that will be available in the future. It is either pending, fulfilled (resolved with a value), or rejected (failed with an error). You chain .then() for success and .catch() for errors. Promise.all() runs multiple promises in parallel and waits for all of them.
// Simulating async operations with Promises
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error('Invalid user ID'));
return;
}
resolve({ id, name: 'Alice', email: 'alice@example.com' });
}, 100); // simulate 100ms network delay
});
}
function fetchPosts(userId) {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, title: 'Hello World', userId },
{ id: 2, title: 'Node.js Tips', userId },
]);
}, 80);
});
}
// Chaining promises
fetchUser(1)
.then(user => {
console.log('User:', user.name);
return fetchPosts(user.id); // return next promise
})
.then(posts => {
console.log('Posts:', posts.length);
posts.forEach(p => console.log(' -', p.title));
})
.catch(err => {
console.log('Error:', err.message);
});
// Promise.all โ run in parallel
Promise.all([fetchUser(1), fetchUser(2)])
.then(([user1, user2]) => {
console.log('Both fetched:', user1.name, '&', user2.name);
});
// Rejected promise
fetchUser(-1)
.then(user => console.log(user))
.catch(err => console.log('Caught:', err.message));Promise.all() is a major performance win. If you need three independent pieces of data, fetching them sequentially takes 3x as long. Promise.all() fetches them in parallel and waits for all to complete โ the total time is just the slowest individual fetch, not the sum.
async / await โ Writing Async Code That Reads Like Sync
async/await is syntactic sugar over Promises โ it makes async code look and read like synchronous code. An async function always returns a Promise. await pauses execution inside the async function until the Promise resolves. Use try/catch for error handling. This is the preferred style in modern Node.js.
// Same operations as before, but with async/await
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchUser(id) {
await delay(50); // simulate network
if (id <= 0) throw new Error('Invalid user ID');
return { id, name: id === 1 ? 'Alice' : 'Bob', role: 'developer' };
}
async function fetchUserPosts(userId) {
await delay(40);
return [
{ title: 'Getting started with Node.js' },
{ title: 'Understanding async/await' },
];
}
// async function โ reads like synchronous code
async function main() {
// Sequential (one after another)
console.log('Fetching user...');
const user = await fetchUser(1);
console.log('Got user:', user.name, '(' + user.role + ')');
console.log('Fetching posts...');
const posts = await fetchUserPosts(user.id);
console.log('Got', posts.length, 'posts:');
posts.forEach(p => console.log(' -', p.title));
// Parallel with Promise.all + await
console.log('\nFetching both users in parallel...');
const [alice, bob] = await Promise.all([fetchUser(1), fetchUser(2)]);
console.log('Users:', alice.name, 'and', bob.name);
// Error handling with try/catch
try {
await fetchUser(-5);
} catch (err) {
console.log('Handled error:', err.message);
}
}
main().catch(console.error);A very common mistake is using await inside a forEach loop โ it does not work as expected. forEach does not wait for async operations. Use for...of instead: for (const item of items) { await doSomething(item); }. Or use Promise.all with .map() for parallel execution.
HTTP & Express
Node's built-in http module lets you create web servers from scratch. Express.js is a minimal web framework built on top of it โ it adds routing, middleware, and request/response helpers. Express is the most popular Node.js framework and the foundation of the MEAN/MERN stacks. Understanding both the raw http module and Express gives you a complete picture of how Node web servers work.
Built-in HTTP Server
The http module is Node's built-in way to create web servers. Every request triggers the callback with req (IncomingMessage) and res (ServerResponse) objects. You manually set status codes, headers, and write the response body. This is low-level โ most real apps use Express on top.
const http = require('http');
const url = require('url');
const PORT = 3000;
// Simple in-memory data store
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
function sendJSON(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data, null, 2));
}
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
const method = req.method;
console.log(`${method} ${pathname}`);
// Route: GET /
if (pathname === '/' && method === 'GET') {
sendJSON(res, 200, { message: 'Node.js API', version: '1.0' });
// Route: GET /users
} else if (pathname === '/users' && method === 'GET') {
sendJSON(res, 200, users);
// Route: GET /users/:id
} else if (pathname.startsWith('/users/') && method === 'GET') {
const id = parseInt(pathname.split('/')[2]);
const user = users.find(u => u.id === id);
if (user) {
sendJSON(res, 200, user);
} else {
sendJSON(res, 404, { error: 'User not found' });
}
// 404 for everything else
} else {
sendJSON(res, 404, { error: 'Route not found' });
}
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});Notice how all routing is manual โ you check pathname and method yourself. This gets painful fast. Express handles routing, parameter parsing, middleware, and response helpers for you. The raw http module is great for understanding how it works, but use Express (or Fastify) for real projects.
Express.js โ Routing and Middleware
Express adds a clean routing API and middleware system on top of Node's http module. Middleware are functions that run before your route handlers โ they can read/modify the request and response, or pass control to the next function. express.json() is built-in middleware that parses JSON request bodies.
// npm install express
const express = require('express');
const app = express();
// Built-in middleware
app.use(express.json()); // parse JSON request bodies
// Custom logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now()-start}ms`);
});
next(); // must call next() to continue
});
// In-memory store
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
let nextId = 3;
// GET all users
app.get('/users', (req, res) => {
const { name } = req.query; // ?name=Alice
const result = name
? users.filter(u => u.name.toLowerCase().includes(name.toLowerCase()))
: users;
res.json(result);
});
// GET user by id (:id is a route parameter)
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST โ create user
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email required' });
}
const user = { id: nextId++, name, email };
users.push(user);
res.status(201).json(user);
});
// DELETE user
app.delete('/users/:id', (req, res) => {
const idx = users.findIndex(u => u.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
users.splice(idx, 1);
res.json({ message: 'Deleted successfully' });
});
app.listen(3000, () => console.log('Express server on port 3000'));Middleware order matters in Express. app.use() registers middleware that runs for every request. Always place middleware before the routes that depend on it. express.json() must come before any route that reads req.body, otherwise req.body will be undefined.
Working with Files
The fs (file system) module gives Node.js the ability to read, write, delete, and watch files โ something browser JavaScript cannot do. Every fs operation has two versions: a synchronous blocking version (fs.readFileSync) and an asynchronous non-blocking version (fs.readFile with callback, or the promise-based fs.promises.readFile). Always use the async versions in servers to avoid blocking the event loop.
Reading and Writing Files
fs.promises provides Promise-based versions of all file operations, which work perfectly with async/await. readFile reads a whole file into memory. writeFile creates or overwrites a file. appendFile adds to the end. For large files, use streams instead of reading everything into memory at once.
const fs = require('fs').promises;
const path = require('path');
async function fileDemo() {
const filePath = path.join(__dirname, 'data.txt');
const jsonPath = path.join(__dirname, 'users.json');
// Write a file
await fs.writeFile(filePath, 'Hello, Node.js!\nLine two.\nLine three.');
console.log('File written.');
// Read it back
const content = await fs.readFile(filePath, 'utf8');
console.log('Content:', content);
// Append to file
await fs.appendFile(filePath, '\nLine four (appended).');
const updated = await fs.readFile(filePath, 'utf8');
console.log('Updated:', updated);
// Write JSON
const users = [
{ id: 1, name: 'Alice', score: 92 },
{ id: 2, name: 'Bob', score: 78 },
];
await fs.writeFile(jsonPath, JSON.stringify(users, null, 2));
console.log('JSON written.');
// Read and parse JSON
const raw = await fs.readFile(jsonPath, 'utf8');
const parsed = JSON.parse(raw);
console.log('Users from JSON:');
parsed.forEach(u => console.log(` ${u.name}: ${u.score}`));
// File metadata
const stat = await fs.stat(filePath);
console.log('Size (bytes):', stat.size);
console.log('Modified:', stat.mtime.toLocaleDateString());
// Delete files
await fs.unlink(filePath);
await fs.unlink(jsonPath);
console.log('Files deleted.');
}
fileDemo().catch(console.error);Always use utf8 as the second argument to readFile. Without it, Node returns a Buffer (raw bytes) instead of a string. This is a very common gotcha for beginners. Also, JSON.stringify(data, null, 2) produces pretty-printed JSON โ the third argument is the indentation level.
Working with Directories
fs also handles directories โ creating them, listing their contents, and checking if they exist. A common pattern is to check if a directory exists before creating it, using mkdir with { recursive: true } which creates the full path and does not throw if it already exists.
const fs = require('fs').promises;
const path = require('path');
async function dirDemo() {
const baseDir = path.join(__dirname, 'output');
// Create directory (recursive: true = no error if exists)
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(path.join(baseDir, 'reports'), { recursive: true });
await fs.mkdir(path.join(baseDir, 'logs'), { recursive: true });
console.log('Directories created.');
// Write some files
await fs.writeFile(path.join(baseDir, 'reports', 'jan.txt'), 'January report');
await fs.writeFile(path.join(baseDir, 'reports', 'feb.txt'), 'February report');
await fs.writeFile(path.join(baseDir, 'logs', 'app.log'), 'Log entry 1');
await fs.writeFile(path.join(baseDir, 'config.json'), '{"debug": true}');
// List directory contents
const entries = await fs.readdir(baseDir, { withFileTypes: true });
console.log('Contents of output/:');
for (const entry of entries) {
const type = entry.isDirectory() ? 'DIR' : 'FILE';
console.log(` [${type}] ${entry.name}`);
}
// List subdirectory
const reports = await fs.readdir(path.join(baseDir, 'reports'));
console.log('Reports:', reports.join(', '));
// Check if path exists
try {
await fs.access(path.join(baseDir, 'config.json'));
console.log('config.json exists: true');
} catch {
console.log('config.json exists: false');
}
// Cleanup
await fs.rm(baseDir, { recursive: true });
console.log('Cleanup done.');
}
dirDemo().catch(console.error);Use fs.rm(path, { recursive: true }) (Node 14+) to delete a directory and all its contents. On older Node versions, use the rimraf npm package. Never use fs.rmdirSync recursively in production โ it is synchronous and will block your server.
npm & Package Management
npm (Node Package Manager) is the world's largest software registry with over 2 million packages. It ships with Node.js automatically. package.json is the manifest file for your project โ it lists your dependencies, scripts, and metadata. node_modules is where installed packages live (never commit it to git). Understanding npm is essential for every Node.js developer.
package.json โ Your Project Manifest
package.json is the heart of every Node.js project. It tracks your dependencies, defines npm scripts (shortcuts for common commands), and contains project metadata. npm init creates one interactively. npm install (or npm i) installs all listed dependencies from package.json.
// package.json โ project manifest
{
"name": "my-api",
"version": "1.0.0",
"description": "A simple Node.js REST API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"lint": "eslint src/**/*.js"
},
"dependencies": {
"express": "^4.18.2",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"eslint": "^8.55.0"
}
}
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Common npm commands (run in terminal):
// npm init -y โ create package.json with defaults
// npm install express โ install & add to dependencies
// npm install -D nodemon โ install & add to devDependencies
// npm install โ install all from package.json
// npm update โ update all packages
// npm uninstall express โ remove a package
// npm run dev โ run the 'dev' script
// npm run test โ run the 'test' script
// npm list โ show installed packages
// npm outdated โ show packages with newer versionsdependencies are packages your app needs to run in production. devDependencies are tools only needed during development (test runners, linters, hot-reloaders). Add devDependencies with npm install -D. When you deploy to production, you can run npm install --omit=dev to skip installing them.
Environment Variables with dotenv
Never hardcode secrets (database URLs, API keys, passwords) in your code. Use environment variables instead. The dotenv package loads variables from a .env file into process.env. The .env file is kept out of git with .gitignore. This is standard practice for all Node.js applications.
// .env file (NEVER commit this to git)
// PORT=3000
// DB_URL=mongodb://localhost:27017/myapp
// JWT_SECRET=super_secret_key_here
// NODE_ENV=development
// API_KEY=abc123xyz
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// config.js โ centralise all config
require('dotenv').config();
const config = {
port: parseInt(process.env.PORT) || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
db: {
url: process.env.DB_URL || 'mongodb://localhost:27017/dev',
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
},
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
};
// Validate required vars at startup
const required = ['JWT_SECRET', 'DB_URL'];
for (const key of required) {
if (!process.env[key]) {
console.error(`Missing required env var: ${key}`);
process.exit(1); // crash fast on missing config
}
}
module.exports = config;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// server.js
const config = require('./config');
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({
env: config.nodeEnv,
port: config.port,
debug: config.isDevelopment,
});
});
app.listen(config.port, () => {
console.log(`Running in ${config.nodeEnv} mode on port ${config.port}`);
});Always add .env to your .gitignore immediately. Create a .env.example file with the variable names but no values and commit that instead. This tells other developers which environment variables they need to set up without exposing your secrets.
REST API Project
This module puts everything together into a complete, structured REST API project. We will build a books API with full CRUD (Create, Read, Update, Delete) operations, input validation, error handling middleware, and a clean project structure. This is the kind of code you would write as a junior Node.js backend developer.
Project Structure and Router Pattern
A well-structured Express project separates routes, logic, and configuration into separate files. Express Router lets you define routes in separate modules and mount them at a prefix. This keeps your main server.js clean and makes each feature easy to find and test independently.
// Project structure:
// my-api/
// โโโ package.json
// โโโ .env
// โโโ src/
// โ โโโ index.js โ entry point
// โ โโโ config.js โ env config
// โ โโโ middleware/
// โ โ โโโ errorHandler.js
// โ โโโ routes/
// โ โโโ books.js โ books router
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// src/routes/books.js
const express = require('express');
const router = express.Router();
let books = [
{ id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas', year: 1999 },
{ id: 2, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 },
{ id: 3, title: 'You Don\'t Know JS', author: 'Kyle Simpson', year: 2015 },
];
let nextId = 4;
// GET /api/books โ list with optional ?author= filter
router.get('/', (req, res) => {
const { author } = req.query;
const result = author
? books.filter(b => b.author.toLowerCase().includes(author.toLowerCase()))
: books;
res.json({ count: result.length, books: result });
});
// GET /api/books/:id
router.get('/:id', (req, res, next) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return next({ status: 404, message: 'Book not found' });
res.json(book);
});
// POST /api/books
router.post('/', (req, res, next) => {
const { title, author, year } = req.body;
if (!title || !author) {
return next({ status: 400, message: 'title and author are required' });
}
const book = { id: nextId++, title, author, year: year || new Date().getFullYear() };
books.push(book);
res.status(201).json(book);
});
// PATCH /api/books/:id
router.patch('/:id', (req, res, next) => {
const idx = books.findIndex(b => b.id === parseInt(req.params.id));
if (idx === -1) return next({ status: 404, message: 'Book not found' });
books[idx] = { ...books[idx], ...req.body };
res.json(books[idx]);
});
// DELETE /api/books/:id
router.delete('/:id', (req, res, next) => {
const idx = books.findIndex(b => b.id === parseInt(req.params.id));
if (idx === -1) return next({ status: 404, message: 'Book not found' });
const [deleted] = books.splice(idx, 1);
res.json({ message: 'Deleted', book: deleted });
});
module.exports = router;Notice the next({ status, message }) pattern โ passing an object to next() triggers Express's error handler middleware. This keeps error handling logic in one place instead of scattered across every route. One error handler middleware handles all errors from all routes.
Server Entry Point and Error Middleware
The entry point wires everything together: loads config, registers middleware, mounts routers, and registers the error handler last. Express error handlers are middleware with four parameters (err, req, res, next) โ Express identifies them by the four arguments. They must be registered after all routes.
// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
// Log server errors
if (status >= 500) {
console.error('[ERROR]', req.method, req.path, '-', message);
}
res.status(status).json({
error: message,
status,
path: req.path,
timestamp: new Date().toISOString(),
});
}
module.exports = errorHandler;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// src/index.js โ entry point
require('dotenv').config();
const express = require('express');
const booksRouter = require('./routes/books');
const errorHandler = require('./middleware/errorHandler');
const app = express();
const PORT = process.env.PORT || 3000;
// Global middleware
app.use(express.json());
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime().toFixed(1) + 's' });
});
// Mount routers
app.use('/api/books', booksRouter);
// 404 handler โ must be after all routes
app.use((req, res, next) => {
next({ status: 404, message: `Route ${req.method} ${req.path} not found` });
});
// Error handler โ must be last, 4 params
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`API server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});The error handler must be the last app.use() in your server โ after all routes and other middleware. Express identifies error-handling middleware by the four-parameter signature (err, req, res, next). If you only have three parameters, Express will not treat it as an error handler.
You finished the Node.js tutorial!
You now know Node modules, async/await, Express routing, file I/O, npm, and how to structure a complete REST API. These skills are the foundation of all Node.js backend development.