Topic 2: Node Projects
📖 8 min read · 🎯 beginner · 🧭 Prerequisites: the-model-view-controller-pattern, configuring-express
Why this matters
Up until now, you've probably written a few Node.js lines and run them — and that's great. But "writing some code" and "building a real Node project" are two very different things. When you sit down to actually build something — a REST API, a chat app, a command-line tool — you need to know how to structure it from the start. What files go where? How does everything connect? In this lesson, we build three real things: a RESTful API, a live chat room with Socket.io, and a CLI tool with yargs. By the end, you'll know exactly how a Node project comes together.
What You'll Learn
- Scaffold and run a CRUD RESTful API with Express.js
- Build a real-time chat application using Socket.io on a shared HTTP server
- Create a CLI task manager with persistent JSON storage using yargs
- Understand how each project type maps to a different Node.js capability
The Analogy
Think of Node.js as a Swiss Army knife sitting on a workbench. One blade is an Express HTTP server — perfect for answering requests like a librarian handing out books on demand. A second blade is Socket.io — a two-way radio that keeps all connected parties talking in real time. A third blade is a file-system-backed CLI — a sticky notepad on the bench you can write to and read from any time. Each blade is the same knife, just opened in a different direction to solve a different problem.
Chapter 1: Project 1 — Simple RESTful API
the trainer called up the first blueprint: a book registry API exposing the classic five CRUD operations over HTTP.
Step 1: Set Up the Project
mkdir books-api
cd books-api
npm init -y
npm install express
Step 2: Create the Server
server.js — the entire API lives in one file for this project:
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
let books = [
{ id: 1, title: '1984', author: 'George Orwell' },
{ id: 2, title: 'Brave New World', author: 'Aldous Huxley' },
];
// Get all books
app.get('/books', (req, res) => {
res.json(books);
});
// Get a book by ID
app.get('/books/:id', (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return res.status(404).send('Book not found');
res.json(book);
});
// Add a new book
app.post('/books', (req, res) => {
const book = {
id: books.length + 1,
title: req.body.title,
author: req.body.author,
};
books.push(book);
res.status(201).json(book);
});
// Update a book
app.put('/books/:id', (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return res.status(404).send('Book not found');
book.title = req.body.title;
book.author = req.body.author;
res.json(book);
});
// Delete a book
app.delete('/books/:id', (req, res) => {
const bookIndex = books.findIndex(b => b.id === parseInt(req.params.id));
if (bookIndex === -1) return res.status(404).send('Book not found');
const deletedBook = books.splice(bookIndex, 1);
res.json(deletedBook);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Key details worth noting:
express.json()middleware parses incoming request bodies as JSON automatically.- The in-memory
booksarray acts as a temporary data store — it resets on server restart. - Route parameter
:idis always a string;parseInt(req.params.id)converts it for the array lookup. - Status codes follow HTTP conventions:
201 Createdon POST,404 Not Foundwhen the book is missing.
Step 3: Run the Server
node server.js
The API is now live at http://localhost:3000. Use Postman or cURL to test each route:
| Method | Route | Action |
|---|---|---|
| GET | /books | List all books |
| GET | /books/:id | Get one book |
| POST | /books | Add a book |
| PUT | /books/:id | Update a book |
| DELETE | /books/:id | Delete a book |
Chapter 2: Project 2 — Real-Time Chat with Socket.io
"Now for something that talks back in real time," said the trainer, rolling out the second blueprint.
Socket.io layers a persistent, bidirectional WebSocket channel on top of a standard Node http server. The same port serves both the HTML client and the Socket.io upgrade handshake.
Step 1: Set Up the Project
mkdir chat-app
cd chat-app
npm init -y
npm install express socket.io
Step 2: Create the Server
server.js:
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
io.on('connection', (socket) => {
console.log('a user connected');
socket.on('chat message', (msg) => {
io.emit('chat message', msg);
});
socket.on('disconnect', () => {
console.log('user disconnected');
});
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Architecture notes:
http.createServer(app)wraps the Express app so Socket.io can share the same TCP port.io.on('connection', ...)fires once per new browser tab that connects.socket.on('chat message', ...)listens for a message from one client.io.emit('chat message', msg)broadcasts that message to all connected clients (including the sender).
Step 3: Create the Frontend
index.html — served statically from the same Node process:
<!DOCTYPE html>
<html>
<head>
<title>Chat App</title>
<style>
ul { list-style-type: none; padding: 0; }
li { padding: 8px; margin-bottom: 10px; background: #f4f4f4; }
input { padding: 10px; width: 80%; }
button { padding: 10px; }
</style>
</head>
<body>
<ul id="messages"></ul>
<form id="form" action="">
<input id="input" autocomplete="off" /><button>Send</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
const form = document.getElementById('form');
const input = document.getElementById('input');
form.addEventListener('submit', function(e) {
e.preventDefault();
if (input.value) {
socket.emit('chat message', input.value);
input.value = '';
}
});
socket.on('chat message', function(msg) {
const item = document.createElement('li');
item.textContent = msg;
document.getElementById('messages').appendChild(item);
window.scrollTo(0, document.body.scrollHeight);
});
</script>
</body>
</html>
/socket.io/socket.io.js is served automatically by the Socket.io server — no separate install or CDN link needed.
Step 4: Run the Server
node server.js
Open http://localhost:3000 in two browser tabs. Type a message in one tab and watch it appear instantly in both — that is the Socket.io broadcast in action.
sequenceDiagram
participant ClientA as Browser A
participant Server as Node + Socket.io
participant ClientB as Browser B
ClientA->>Server: socket.emit('chat message', 'Hello!')
Server->>ClientA: io.emit('chat message', 'Hello!')
Server->>ClientB: io.emit('chat message', 'Hello!')
Chapter 3: Project 3 — Task Management CLI Tool
"The third project never opens a browser at all," the trainer said with a grin. "Pure command line."
This tool stores tasks in a local tasks.json file and exposes three subcommands — add, list, and remove — via the yargs library.
Step 1: Set Up the Project
mkdir task-cli
cd task-cli
npm init -y
npm install yargs
Step 2: Create the CLI Tool
task.js:
const yargs = require('yargs');
const fs = require('fs');
const tasksFile = 'tasks.json';
// Load tasks
const loadTasks = () => {
try {
const dataBuffer = fs.readFileSync(tasksFile);
return JSON.parse(dataBuffer.toString());
} catch (e) {
return [];
}
};
// Save tasks
const saveTasks = (tasks) => {
fs.writeFileSync(tasksFile, JSON.stringify(tasks));
};
// Add task command
yargs.command({
command: 'add',
describe: 'Add a new task',
builder: {
title: {
describe: 'Task title',
demandOption: true,
type: 'string'
},
description: {
describe: 'Task description',
demandOption: true,
type: 'string'
}
},
handler(argv) {
const tasks = loadTasks();
tasks.push({ title: argv.title, description: argv.description });
saveTasks(tasks);
console.log('Task added:', argv.title);
}
});
// List tasks command
yargs.command({
command: 'list',
describe: 'List all tasks',
handler() {
const tasks = loadTasks();
console.log('Tasks:');
tasks.forEach((task, index) => {
console.log(`${index + 1}. ${task.title} - ${task.description}`);
});
}
});
// Remove task command
yargs.command({
command: 'remove',
describe: 'Remove a task',
builder: {
title: {
describe: 'Task title',
demandOption: true,
type: 'string'
}
},
handler(argv) {
let tasks = loadTasks();
tasks = tasks.filter((task) => task.title !== argv.title);
saveTasks(tasks);
console.log('Task removed:', argv.title);
}
});
yargs.parse();
How the pieces fit together:
yargs.command({ command, describe, builder, handler })— each call registers one subcommand.builderdefines named flags;demandOption: truemakes a flag required.argvinsidehandlerholds the parsed flag values.loadTaskswraps the file read in a try/catch so a missingtasks.jsonsilently returns an empty array.saveTasksoverwrites the file on every mutation — simple but sufficient for a local CLI.
Step 3: Run the CLI Tool
node task.js add --title="Finish report" --description="Complete the annual report"
node task.js list
node task.js remove --title="Finish report"
node task.js list
Expected output:
Task added: Finish report
Tasks:
1. Finish report - Complete the annual report
Task removed: Finish report
Tasks:
(The final list prints nothing because the task was removed and the file is now an empty array.)
🧪 Try It Yourself
Extend the books API to persist data to disk.
Right now books lives in memory and resets every time you restart server.js. Replace it with file-based persistence the same way task-cli does it.
Task: In the books-api project, add two helper functions — loadBooks() and saveBooks(books) — that read from and write to a books.json file. Wire them into every route handler so the data survives a server restart.
Starter snippet:
const fs = require('fs');
const BOOKS_FILE = 'books.json';
const loadBooks = () => {
try {
return JSON.parse(fs.readFileSync(BOOKS_FILE).toString());
} catch (e) {
return [];
}
};
const saveBooks = (books) => {
fs.writeFileSync(BOOKS_FILE, JSON.stringify(books, null, 2));
};
Success criterion: Stop the server with Ctrl+C, restart it with node server.js, then GET /books and see the books you previously POSTed still present in the response.
🔍 Checkpoint Quiz
Q1. Why does the chat application use http.createServer(app) instead of calling app.listen() directly?
A) Express requires it when using middleware
B) Socket.io needs a raw http.Server instance to attach its WebSocket layer to
C) app.listen() is deprecated in newer versions of Express
D) http.createServer is faster than app.listen
Q2. Given this snippet from task.js:
const loadTasks = () => {
try {
const dataBuffer = fs.readFileSync(tasksFile);
return JSON.parse(dataBuffer.toString());
} catch (e) {
return [];
}
};
What does this function return when tasks.json does not yet exist?
A) null
B) undefined
C) []
D) It throws a FileNotFoundError
Q3. In the books API, a POST /books request arrives with the body { "title": "Dune", "author": "Frank Herbert" }. What HTTP status code does the server send back, and why?
A) 200 — the default success code B) 201 — signalling that a new resource was created C) 204 — no content, creation was silent D) 202 — the request was accepted for processing
Q4. You want to add a complete command to task-cli that marks a task done without deleting it. Which yargs builder property would you use to make the --title flag required for that command?
A) required: true
B) optional: false
C) demandOption: true
D) mandatory: true
A1. B — Socket.io attaches its WebSocket upgrade listener directly to the http.Server instance. app.listen() creates an internal server you can't reference, so there is no object to pass to socketIo().
A2. C — fs.readFileSync throws when the file is missing; the catch block returns [], giving callers a safe empty array to push into.
A3. B — HTTP 201 Created is the correct status for a successful POST that produces a new resource. Using 200 would not be wrong but is less precise.
A4. C — demandOption: true is the yargs property that makes a flag mandatory. The others (required, optional, mandatory) are not valid yargs builder keys.
🪞 Recap
- A RESTful API with Express maps HTTP verbs (
GET,POST,PUT,DELETE) to route handlers that read and mutate an in-memory (or file-backed) data store. - Socket.io enables real-time bidirectional communication by sharing a raw
http.Serverwith Express;io.emitbroadcasts to all connected clients simultaneously. - yargs turns a Node.js script into a structured CLI with subcommands, typed flags, and built-in
--helpoutput — backed by plainfsreads and writes for persistence. - All three projects start with the same two steps:
npm init -yandnpm install <dependencies>. - The in-memory
booksarray andtasks.jsonfile are entry-level storage patterns; real applications replace them with a database, but the route and command structure stays the same.
📚 Further Reading
- Express.js routing docs — the source of truth on defining routes and middleware
- Socket.io — Get Started — official chat tutorial that goes deeper into rooms and namespaces
- yargs documentation — complete reference for commands, builders, and option types
- ⬅️ Previous: Configuring Express
- ➡️ Next: Postman Configuration