WEB API's Lesson 31 – Mini Project - REST API | Dataplexa
Web APIs · Lesson 31

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.

Project Scope
Stack: Node.js + Express. Data store: in-memory array (no database required — this is a prototype). Four CRUD endpoints on a /api/members resource. Input validation on POST and PUT. Structured JSON error responses. Correct status codes throughout.
What you build
A 5-endpoint REST API with full CRUD, validation, and error handling — running locally on port 3000.
What you practise
Resource modelling, route design, HTTP semantics, status codes, error shape conventions, and middleware.
Skills applied
Lessons 1–30: REST principles, CRUD, pagination, versioning, error handling, and authentication basics.
Time estimate
45–75 minutes if you type the code yourself. Shorter if you copy, longer if you extend it with extras.

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

1
Initialise the project and install Express

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 express
Wrote to /apiforge-members-api/package.json: { "name": "apiforge-members-api", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" } added 65 packages, and audited 66 packages in 2s found 0 vulnerabilities
What just happened?

npm 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

2
Create index.js — the Express app and seed 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}`);
});
$ node index.js APIForge Members API running on http://localhost:3000
What just happened?

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

3
Add GET /api/members and GET /api/members/:id

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
  });
});
# GET /api/members HTTP/1.1 200 OK Content-Type: application/json { "success": true, "count": 5, "data": [ { "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" } ] } # GET /api/members/99 (non-existent) HTTP/1.1 404 Not Found Content-Type: application/json { "success": false, "error": "Member not found" }
What just happened?

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

4
Add POST /api/members and PUT /api/members/:id

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 /api/members with valid body HTTP/1.1 201 Created Content-Type: application/json { "success": true, "data": { "id": 6, "name": "Lena Fischer", "email": "lena@apiforge.dev", "team": "Backend", "role": "Junior Engineer" } } # POST /api/members with missing email field HTTP/1.1 400 Bad Request Content-Type: application/json { "success": false, "error": "Missing required fields: name, email, team, role" } # PUT /api/members/2 — update Marcus to a new role HTTP/1.1 200 OK Content-Type: application/json { "success": true, "data": { "id": 2, "name": "Marcus Webb", "email": "marcus@apiforge.dev", "team": "Frontend", "role": "Engineering Manager" } }
What just happened?

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

5
Add DELETE /api/members/:id and wire up a 404 catch-all

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}`
  });
});
# DELETE /api/members/3 HTTP/1.1 200 OK Content-Type: application/json { "success": true, "message": "Member deleted", "data": { "id": 3, "name": "Seo-Yeon Park", "email": "seoyeon@apiforge.dev", "team": "DevOps", "role": "Platform Engineer" } } # DELETE /api/members/3 (again — already removed) HTTP/1.1 404 Not Found Content-Type: application/json { "success": false, "error": "Member not found" } # GET /api/unicorns (unknown route) HTTP/1.1 404 Not Found Content-Type: application/json { "success": false, "error": "Cannot GET /api/unicorns" }
What just happened?

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.

Without this API

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.

With this API

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.

What the APIForge Backend team shipped
5 REST endpoints
Input validation
Consistent JSON envelope
Correct status codes
404 catch-all
In-memory CRUD
Extension challenges

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?

Up Next
Mini Project: Auth API
The APIForge Security team locks down the Members API with registration, login, and token-based authentication.