Web APIs
Mini Project: Build a REST API from Scratch
You build a fully functional REST API — endpoints, data, error handling, and real HTTP responses — using Node.js and Express.
Most API concepts click the moment you ship your first working endpoint. Reading about status codes is fine. Watching a 200 OK land in a tool you just called yourself — that is a different experience entirely.
This project puts everything from the first 30 lessons into a single codebase. You design the resource model, write the route handlers, enforce HTTP semantics, and handle errors the way a production API would. By the end, you have a real running API — not a toy snippet, but something a frontend team could actually consume.
Project Brief
The APIForge Backend team has been asked to prototype a Team Members API — an internal service that tracks developer profiles across the five engineering teams. Product wants a working API by end of sprint so the Frontend team can start building the team directory dashboard against real endpoints.
The API needs to support creating, reading, updating, and deleting team member records. It should return consistent JSON, use correct HTTP methods and status codes, and handle bad input gracefully rather than crashing or returning vague errors.
/api/members resource. Input validation on POST and PUT. Structured JSON error responses. Correct status codes throughout.
The API You Are Building
Before writing a single line, map out what the API does. This is the resource model step — deciding what the resource is called, what shape its data takes, and which HTTP operations map to which endpoints.
The resource here is a member. Each member has an id, a name, an email, a team, and a role. Five endpoints cover everything the Frontend team needs.
| Method | Path | Action | Success Code |
|---|---|---|---|
GET |
/api/members |
Return all members | 200 |
GET |
/api/members/:id |
Return single member | 200 |
POST |
/api/members |
Create new member | 201 |
PUT |
/api/members/:id |
Replace member record | 200 |
DELETE |
/api/members/:id |
Remove member | 200 |
Step 1 — Project Setup and Dependencies
Every Node.js project starts with a package.json. This file records the project name, version, and the libraries it depends on. Running npm init -y generates it automatically with sensible defaults — the -y flag skips the interactive questionnaire.
Express is a minimal web framework for Node.js. It wraps Node's built-in HTTP module and gives you a clean way to define routes — without Express, you would be writing raw socket handlers and parsing URLs by hand, which is both tedious and error-prone.
# WHAT: Initialise project and install Express for the APIForge Team Members API
mkdir apiforge-members-api
cd apiforge-members-api
npm init -y
npm install expressnpm created a package.json that tracks this project's identity and dependencies. When you ran npm install express, npm downloaded Express and all its peer dependencies into a node_modules folder and added an entry to package.json under "dependencies".
Try this: open package.json and confirm Express appears under dependencies. Then check node_modules/express/package.json to see what version was installed.
Step 2 — Server Bootstrap and In-Memory Data
This file is the entry point. It creates an Express app instance, attaches the JSON body-parsing middleware — which tells Express to read incoming request bodies as JSON automatically — and defines a starting data set so the API has something to return on day one.
The in-memory array acts as a stand-in for a database. Every member gets a numeric id. A counter variable tracks the next id to assign, the same pattern real databases use with auto-increment columns.
// WHAT: Bootstrap the Express server and seed the in-memory data store
// File: index.js
const express = require('express');
const app = express();
const PORT = 3000;
// Middleware: parse JSON request bodies automatically
app.use(express.json());
// In-memory data store — simulates a database for this prototype
let members = [
{ id: 1, name: 'Priya Nair', email: 'priya@apiforge.dev', team: 'Backend', role: 'Senior Engineer' },
{ id: 2, name: 'Marcus Webb', email: 'marcus@apiforge.dev', team: 'Frontend', role: 'UI Lead' },
{ id: 3, name: 'Seo-Yeon Park', email: 'seoyeon@apiforge.dev', team: 'DevOps', role: 'Platform Engineer' },
{ id: 4, name: 'Aisha Kamara', email: 'aisha@apiforge.dev', team: 'Security', role: 'AppSec Engineer' },
{ id: 5, name: 'Tom Reyes', email: 'tom@apiforge.dev', team: 'Product', role: 'Technical PM' }
];
// Auto-increment counter — next ID to assign on POST
let nextId = 6;
// Routes will go here (Steps 3–5)
app.listen(PORT, () => {
console.log(`APIForge Members API running on http://localhost:${PORT}`);
});express.json() is a middleware function. Middleware sits between an incoming request and your route handler — it runs first, transforms something, then passes control along. Without this line, req.body would always be undefined when clients POST JSON.
Try this: run node index.js and confirm the server starts. Then open your browser to http://localhost:3000 — you should get a "Cannot GET /" message, which means Express is running but you have not defined a root route yet. That is fine.
Step 3 — GET Endpoints: Listing and Fetching Members
The collection endpoint returns every member. The individual endpoint uses a URL parameter — the :id token in the path — to filter down to one. Express captures whatever value appears in that position in the URL and makes it available on req.params.id.
Notice that the id coming in from the URL is a string — "3" not 3. The parseInt() call converts it. Without that conversion, the strict equality check against numeric IDs in the array would never match.
// WHAT: GET routes — list all members and fetch one by ID
// Add these before app.listen() in index.js
// GET /api/members — return full member list
app.get('/api/members', (req, res) => {
res.status(200).json({
success: true,
count: members.length,
data: members
});
});
// GET /api/members/:id — return a single member
app.get('/api/members/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const member = members.find(m => m.id === id);
if (!member) {
return res.status(404).json({
success: false,
error: 'Member not found'
});
}
res.status(200).json({
success: true,
data: member
});
});Both routes wrap their responses in a consistent envelope: a success boolean and a data field. This pattern — the same shape on success and a success: false plus an error string on failure — is what the Frontend team can rely on without checking HTTP status codes alone.
Try this: test GET /api/members/abc (non-numeric id). What status code do you get? Is the response shape consistent with your 404 case? It should be — because parseInt("abc", 10) returns NaN, which will never match any id.
Step 4 — POST and PUT: Creating and Updating Members
POST creates a new record. The request body carries the member data — name, email, team, role. Before doing anything with that data, the handler checks that all four fields are present. Sending a member with no email is not just inconvenient — it breaks downstream systems that rely on that field, so the API rejects it immediately with a 400 before any data gets written.
PUT replaces the full record for a given id. It finds the existing member, merges the incoming fields over it, and saves the updated object back into the array. The response returns the updated member so the caller can confirm exactly what was stored.
// WHAT: POST and PUT routes — create and replace member records
// POST /api/members — create a new member
app.post('/api/members', (req, res) => {
const { name, email, team, role } = req.body;
// Validate: all four fields required
if (!name || !email || !team || !role) {
return res.status(400).json({
success: false,
error: 'Missing required fields: name, email, team, role'
});
}
const newMember = { id: nextId++, name, email, team, role };
members.push(newMember);
res.status(201).json({
success: true,
data: newMember
});
});
// PUT /api/members/:id — replace an existing member record
app.put('/api/members/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = members.findIndex(m => m.id === id);
if (index === -1) {
return res.status(404).json({
success: false,
error: 'Member not found'
});
}
const { name, email, team, role } = req.body;
if (!name || !email || !team || !role) {
return res.status(400).json({
success: false,
error: 'Missing required fields: name, email, team, role'
});
}
members[index] = { id, name, email, team, role };
res.status(200).json({
success: true,
data: members[index]
});
});POST returns 201 Created — not 200. That single digit change tells the caller something was written, not just read. Many HTTP clients and caching layers treat 201 differently from 200, so using the right code matters beyond aesthetics.
PUT uses findIndex rather than find because it needs the position in the array, not just the object, so it can replace the record at that exact slot.
Try this: send a POST with an empty object {} as the body and confirm you get a 400. Then send a valid POST and immediately PUT to that new member's id to verify the update path works end to end.
Step 5 — DELETE and the Finished API
DELETE removes a member by id. The handler uses splice to cut the record out of the array and returns a confirmation with the deleted object so the caller knows exactly what was removed.
The catch-all route at the end is a wildcard that matches any path Express has not already handled. Without it, unknown endpoints produce Express's default HTML error page — a jarring inconsistency when every other response in the API is JSON.
// WHAT: DELETE route + 404 catch-all — complete the full CRUD surface
// DELETE /api/members/:id — remove a member
app.delete('/api/members/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = members.findIndex(m => m.id === id);
if (index === -1) {
return res.status(404).json({
success: false,
error: 'Member not found'
});
}
const deleted = members.splice(index, 1)[0];
res.status(200).json({
success: true,
message: 'Member deleted',
data: deleted
});
});
// 404 catch-all — handles unknown routes consistently
app.use((req, res) => {
res.status(404).json({
success: false,
error: `Cannot ${req.method} ${req.path}`
});
});splice(index, 1) removes one element starting at index and returns an array of what was removed. The [0] at the end pulls the deleted item out of that single-element array so it can go into the response.
The catch-all uses app.use() instead of app.get() — it matches every HTTP method, so a wrong-path POST gets the same clean JSON 404 as a wrong-path GET.
Try this: delete member id 1, then GET /api/members and confirm the count dropped to 4. Then try to delete id 1 again and verify the 404 path fires.
Before and After: Life Without This API
It is worth pausing to feel the difference this API makes. Before it existed, the Frontend team had two options: hardcode fake data into the dashboard, or wait for someone to build a shared spreadsheet. Neither scales past one sprint.
Frontend hardcodes a members.js file. Data goes stale the moment someone joins or leaves. The team directory dashboard ships with five names — forever.
Updating requires a frontend code change, a PR review, and a deploy. A five-second data edit becomes a forty-minute engineering event.
Frontend calls GET /api/members on page load. The directory always reflects current data. Adding a new hire is one POST request — no frontend change needed.
Any team with API access — dashboard, Slack bot, internal CLI — can read and update member data without touching the frontend codebase.
Final Result Summary
The completed index.js is around 90 lines. It handles five routes, validates input on write operations, returns consistent JSON shapes, uses the right HTTP status codes for every outcome, and catches unknown routes cleanly. That is a genuinely useful prototype — not a hello-world demo.
Ready to push further? Try adding a ?team=Backend query parameter to GET /api/members that filters by team. Add a PATCH route that accepts partial updates instead of requiring all four fields. Or wire up a simple API key check using a middleware function that reads an x-api-key header before any route runs.
Each of those three changes maps directly to a lesson from the first 30: Pagination and Filtering (Lesson 14), CRUD Operations (Lesson 13), and API Keys (Lesson 19). Building is faster when the concepts are already familiar.
Quiz
1. The APIForge Backend team POSTs a new member to /api/members and the record is successfully saved. Which HTTP status code should the API return?
2. A developer on the APIForge team sets up an Express POST route but finds that req.body is always undefined, even when sending valid JSON. What is the most likely cause?
3. The APIForge API includes app.use() as the last route in index.js. What does this catch-all do and why does its position at the end matter?