Topic 10: REST APIs
📖 11 min read · 🎯 advanced · 🧭 Prerequisites: building-a-http-server-with-node-js-using-http-apis, socket-io-the-front-end-and-a-chat-app
Why this matters
Up until now, you've built Node servers that mostly talk to a browser on your own machine. But here's the thing — the real world expects your server to speak a shared language that any client can understand, whether it's a browser, a mobile app, or someone else's server entirely. That shared language is REST. Four simple verbs: GET to fetch data, POST to create it, PUT to replace it, DELETE to remove it. Today we build an Express REST API from scratch — and once you know this, you can connect anything to anything.
What You'll Learn
- Initialize a Node.js project and install Express.js as a REST framework
- Define GET, POST, PUT, and DELETE endpoints with Express route handlers
- Manage in-memory resource data using standard HTTP status codes
- Test all CRUD operations against a running API with Postman
The Analogy
Think of a REST API as the front desk of a grand hotel registry. Guests (clients) walk up and make requests: "Show me room 12," "Book a new room," "Change the name on room 7," "Check out of room 3." The front-desk clerk (your Express server) looks up the registry (your data store), performs the requested operation, and hands back a formal receipt — a status code and a JSON response. Every interaction follows the same polite protocol, so any guest who knows the rules can use any front desk in the world.
Chapter 1: Setting Up the Workshop
Before writing a single route, the project environment needs to be ready. Three steps get you there.
1. Install Node.js and npm
Download Node.js from the official site — npm ships with it automatically. npm (Node Package Manager) is the toolbox that installs every library you need.
2. Initialize a new project
Create a project directory, then run:
npm init -y
This generates a package.json file that tracks all project dependencies and metadata. The -y flag accepts every default prompt automatically.
3. Install Express.js
Express is a flexible, minimalistic Node.js framework designed for building web servers and REST APIs quickly:
npm install express
After this command finishes, express is listed under dependencies in package.json and its files live inside node_modules/.
Chapter 2: Crafting the API
With the environment ready, create a file named app.js and build the API piece by piece.
2.1 — Creating the Server
Require Express, instantiate the app, configure JSON body parsing, and start the listener:
const express = require('express');
const app = express();
const port = 3000;
app.use(express.json()); // parse incoming JSON request bodies
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
express.json() is middleware that reads the raw request body and populates req.body with a parsed JavaScript object — essential for POST and PUT routes.
2.2 — Seeding In-Memory Data
For this lesson the data store is a plain JavaScript array. In production you would swap this for a database, but the route logic stays identical:
let creatures = [
{ id: 1, name: 'Dragon', type: 'Fire' },
{ id: 2, name: 'Phoenix', type: 'Fire' },
{ id: 3, name: 'Unicorn', type: 'Earth' },
];
2.3 — Defining the Endpoints
GET all resources
// Get all creatures
app.get('/creatures', (req, res) => {
res.json(creatures);
});
Returns the full array as JSON with an implicit 200 OK.
GET a single resource by ID
// Get a specific creature by ID
app.get('/creatures/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const creature = creatures.find(c => c.id === id);
if (creature) {
res.json(creature);
} else {
res.status(404).send('Creature not found');
}
});
:id is a route parameter; Express exposes it at req.params.id as a string (e.g., "7"). Parse it with parseInt(req.params.id, 10) and compare with strict === so ESLint's eqeqeq rule and a future TypeScript migration both stay happy. If no match is found, the handler responds with 404 Not Found.
parseIntcaveat:parseInt("7abc", 10)returns7(truncating trailing junk), soGET /creatures/7abcwould match creature 7. For strict validation either swap toNumber(req.params.id)(returnsNaNon"7abc") or guard withNumber.isInteger(Number(req.params.id))and reject early with400.
POST — create a new resource
// Add a new creature
app.post('/creatures', (req, res) => {
const nextId = creatures.reduce((max, c) => Math.max(max, c.id), 0) + 1;
const newCreature = { id: nextId, ...req.body };
creatures.push(newCreature);
res.status(201).json(newCreature);
});
The spread ...req.body merges the client-supplied fields with the auto-generated id. We compute the next id from Math.max of existing ids (rather than creatures.length + 1) so that a POST → DELETE → POST sequence does not produce duplicate ids. 201 Created is the correct success status for resource creation. In production you would typically delegate id assignment to the database (SERIAL, UUID, etc.).
PUT — replace the resource
// Replace a creature (PUT = full replacement, per the HTTP spec)
app.put('/creatures/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = creatures.findIndex(c => c.id === id);
if (index !== -1) {
creatures[index] = { id, ...req.body };
res.json(creatures[index]);
} else {
res.status(404).send('Creature not found');
}
});
PUT replaces the whole resource — any field the client omits is now gone. If you instead want a partial update (change only the fields the client sent), that's a different verb: PATCH.
PATCH — partially update a resource
// Patch a creature (PATCH = merge-style partial update)
app.patch('/creatures/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const creature = creatures.find(c => c.id === id);
if (creature) {
Object.assign(creature, req.body);
res.json(creature);
} else {
res.status(404).send('Creature not found');
}
});
Object.assign(creature, req.body) mutates the found object in place, merging only the keys the client supplied. The two verbs intentionally behave differently: PUT is "here's the new full resource", PATCH is "here's a delta — keep the rest".
DELETE — remove a resource
// Delete a creature
app.delete('/creatures/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = creatures.findIndex(c => c.id === id);
if (index !== -1) {
creatures.splice(index, 1);
res.status(204).send();
} else {
res.status(404).send('Creature not found');
}
});
204 No Content is the conventional response for a successful deletion — the resource is gone, so there is nothing to return in the body.
2.4 — Running the Server
node app.js
The server starts at http://localhost:3000 and is ready to accept requests.
Chapter 3: Testing the API with Postman
With the server running, open Postman (or any HTTP client) and fire each of the following requests to verify correct behavior:
| Method | URL | Expected Status | What to check |
|---|---|---|---|
GET | /creatures | 200 | Array of 3 creatures |
GET | /creatures/1 | 200 | Dragon object |
GET | /creatures/99 | 404 | "Creature not found" |
POST | /creatures | 201 | New creature echoed back |
PUT | /creatures/1 | 200 | Updated Dragon object |
DELETE | /creatures/1 | 204 | Empty body |
For the POST request, set the body to raw → JSON in Postman and send:
{
"name": "Griffin",
"type": "Air"
}
The server should respond with { "id": 4, "name": "Griffin", "type": "Air" }.
Each test validates that the correct HTTP verb triggers the correct handler and that status codes communicate the outcome accurately.
🧪 Try It Yourself
Task: Extend the creatures API with a search endpoint that filters by type.
- Critical first step — choose a path that cannot collide with
/creatures/:id. Express matches routes top-to-bottom; if you registered/creatures/:idbefore/creatures/search, the literal string"search"would be captured asreq.params.idand you would get404 Creature not found. Pick a path that side-steps the issue entirely —/search/creaturesis the safe shape. - Add this route to
app.jsabove theapp.listencall (anywhere before it is fine, because the path no longer overlaps with/creatures/:id):
// GET /search/creatures?type=Fire
app.get('/search/creatures', (req, res) => {
const { type } = req.query;
const results = type
? creatures.filter(c => c.type.toLowerCase() === type.toLowerCase())
: creatures;
res.json(results);
});
- Start the server with
node app.js, then visithttp://localhost:3000/search/creatures?type=Firein your browser or Postman.
Success criterion: You should see a JSON array containing only Dragon and Phoenix — the two creatures with "type": "Fire".
Alternative shape: If you really want the URL to be
/creatures/search, you must register it above/creatures/:idin your route table. The path-segment collision is a classic Express foot-gun; using a distinct prefix (/search/...) sidesteps the issue altogether and is the recommendation for this exercise.
🔍 Checkpoint Quiz
Q1. Why does the POST handler respond with status 201 instead of 200?
A) 200 is reserved for GET requests only
B) 201 Created specifically communicates that a new resource was created, giving clients richer semantic information
C) Express automatically rejects 200 on POST routes
D) 201 skips body serialization for performance
Q2. Given the following handler, what does a DELETE /creatures/7 request return when no creature with ID 7 exists?
app.delete('/creatures/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = creatures.findIndex(c => c.id === id);
if (index !== -1) {
creatures.splice(index, 1);
res.status(204).send();
} else {
res.status(404).send('Creature not found');
}
});
A) 200 with an empty array
B) 204 with no body
C) 404 with the text "Creature not found"
D) 500 because splice throws on a missing index
Q3. What is the role of app.use(express.json()) in the server setup?
A) It converts all responses to JSON automatically
B) It restricts the API to only accept application/json Content-Type headers
C) It parses raw request bodies into req.body so route handlers can read JSON payloads
D) It validates that all JSON bodies conform to a predefined schema
Q4. You are building a REST API for a bookstore. Users should be able to update only the price field of a book without sending the full book object. Which HTTP method best fits this operation?
A) GET /books/:id?price=14.99 — read-with-side-effect
B) PUT /books/:id with the full book object replayed minus the changed price
C) PATCH /books/:id with a body like { "price": 14.99 }, merged via Object.assign
D) POST /books/:id/price because POST is the universal "change something" verb
A1. B — 201 Created is the semantically correct status for resource creation. It tells the client that not only did the request succeed, but a new resource now exists at the server.
A2. C — findIndex returns -1 when no match is found, so the else branch executes and sends 404 with the message "Creature not found".
A3. C — express.json() is body-parsing middleware. Without it, req.body is undefined for JSON payloads, causing POST and PUT handlers to silently lose all submitted data.
A4. C — PATCH /books/:id with Object.assign(book, req.body) merges only the keys the client supplied, leaving every other field untouched. PUT would replace the entire resource (and wipe any field the client omitted); GET must be safe/side-effect-free; POST on a sub-resource works but is less idiomatic than PATCH for a partial field update.
🪞 Recap
npm init -ybootstraps a Node.js project andnpm install expressadds the Express framework.- Express route methods (
app.get,app.post,app.put,app.delete) map HTTP verbs to handler functions. express.json()middleware must be registered before any route that readsreq.body.- HTTP status codes are semantic contracts:
200success,201created,204no content,404not found. - Route parameters (
:id) are accessed viareq.params; query strings viareq.query.
📚 Further Reading
- Express.js Official Docs — the source of truth on routing, middleware, and request/response APIs
- MDN HTTP Methods Reference — canonical definitions of GET, POST, PUT, PATCH, DELETE semantics
- Postman Learning Center — guides for testing REST APIs with Postman
- ⬅️ Previous: Socket.IO — The Front End and a Chat App
- ➡️ Next: None