Building a REST API
This is the payoff phase. Everything you've built so far — routing in Phase 2, the middleware chain in Phase 3, reading the body and validating it in Phase 4 — clicks together here into one working API.
The mental model: five handlers over one collection
Here's the shape to hold in your head before you write a line of code. A REST resource is a collection of things — tasks, users, orders, anything — and almost every operation you'll ever do on a collection is one of five:
| You want to… | HTTP method + path | Express handler |
|---|---|---|
| List them all | GET /api/tasks |
res.json(tasks) |
| Get one by id | GET /api/tasks/:id |
find, or 404 |
| Create a new one | POST /api/tasks |
validate, add, 201 |
| Update one | PUT /api/tasks/:id |
find + replace, or 404 |
| Delete one | DELETE /api/tasks/:id |
remove, 204, or 404 |
That's it. List, get, create, update, delete. This is not an Express idea — it's
how REST works in every framework, in every language. What changes from framework
to framework is only the costume: in Django it's a ViewSet, in Rails a
controller, in Express it's five route handlers wired onto a Router.
💡 If you internalize "a resource = these five handlers," learning any new web framework becomes a game of "where do they put the five?" The concept transfers; only the syntax is new.
We'll build the running tasks API — the same little service this guide has been growing all along.
The setup: a Router, express.json(), and a store
Three pieces before the handlers. First, mount a Router for the resource — a
mini-app you attach all the task routes to, then plug into the main app under one
path. Second, add express.json() so incoming JSON bodies actually get parsed.
Third, a place to keep the data.
const express = ;
const app = ;
app.; // parse JSON request bodies into req.body
const router = express.;
// In-memory store — a stand-in for a database
let tasks = ;
let nextId = 1;
app.; // every router route lives under /api/tasks
app.;
What just happened: express.Router() gives us an isolated bundle of routes.
app.use('/api/tasks', router) mounts that bundle so a route defined as '/' on
the router answers at /api/tasks, and '/:id' answers at /api/tasks/:id. The
app.use(express.json()) line is doing real work — without it, req.body would be
undefined and every create/update would silently fail. The store is two
variables: an array of tasks and a counter for handing out unique ids.
📝 Why a plain array is safe here. Node runs your JavaScript on a single thread per process, so two requests never mutate
tasksat the same instant — there's no torn read, no lost update, no need for locks. That's a genuine convenience for a demo. It is not a substitute for a database: the array vanishes when the process restarts, and it doesn't survive across multiple processes if you scale out. Treat it as scaffolding.
The five handlers
Now the heart of it. We define all five on the router. Watch how each one maps to a row in that table above — and how create and update reuse the validation idea from Phase 4.
// LIST — GET /api/tasks
router.;
// GET ONE — GET /api/tasks/:id
router.;
// CREATE — POST /api/tasks
router.;
// UPDATE — PUT /api/tasks/:id
router.;
// DELETE — DELETE /api/tasks/:id
router.;
What just happened: each handler is small and does one job. A few details earn their keep:
Number(req.params.id)— route params always arrive as strings. The store uses numeric ids, so'3' === 3would befalseand every lookup would miss. Coercing once at the top fixes it.return res.status(...)— thereturnmatters. Without it, the handler keeps running after sending the 404 and tries to send a second response, which throws "Cannot set headers after they are sent."- The status codes are the API's vocabulary.
200(the default forres.json) means "here it is."201 Createdmeans "I made it, here's the new thing."204 No Contentmeans "done, nothing to send back" — which is why delete usesres.sendStatus(204)instead ofres.json(...).400means "your input was bad,"404means "no such thing." - Create and update validate before touching the store. That's the Phase 4
habit: check the body, reject early with
400, only then mutate.
Trying it out
With the server running, drive it from another terminal with curl. Walk down the
list and you'll see every status code from the table.
# Create one
# → 201 {"id":1,"title":"Write the README","done":false}
# List them
# → 200 [{"id":1,"title":"Write the README","done":false}]
# Mark it done
# → 200 {"id":1,"title":"Write the README","done":true}
# Ask for one that doesn't exist
# → HTTP/1.1 404 Not Found
# {"error":"Task not found"}
# Delete it
# → HTTP/1.1 204 No Content
What just happened: you exercised all five handlers and saw the four status codes
the API speaks. The -i flag prints the response headers, which is how you confirm
the 404 and 204 — a 204 has an empty body by design, so the status line is
the only signal you get. The -H "Content-Type: application/json" header is what
tells express.json() to parse the body; drop it and req.body comes back empty.
The array is a placeholder — and the errors are repetitive
Two honest observations about what you just built.
💡 The in-memory array is a database stand-in. The whole point of keeping the
store behind tasks.find(...), tasks.push(...), and tasks.splice(...) is that
the handlers don't care what's underneath. When you swap the array for a real
database — through an ORM like Prisma or TypeORM, or raw SQL — the handler shapes
barely change: tasks.find(...) becomes await db.task.findUnique(...), and the
201/404/204 logic stays exactly as it is. If the concept is fuzzy, the
how an ORM works guide explains the layer that sits
between your handlers and the database.
⚠️ Notice how repetitive the error handling already is. Look back: the
if (!task) return res.status(404)... block appears in three different handlers,
word for word. The validation 400s repeat too. Right now each handler is its own
little island of error logic. That works, but it doesn't scale — by the time you
have ten resources you'll have copy-pasted that 404 fifty times, and an unhandled
exception in any handler would crash the process. Phase 6
fixes this properly: one centralized error-handling middleware that every handler
delegates to, so the five handlers go back to describing only the happy path.
Recap
- A REST resource is five handlers over one collection: list, get, create, update, delete. The concept is universal; Express just expresses it as routes on a Router.
- The setup is three pieces:
express.Router()for the resource,app.use(express.json())to parse bodies, and a store (here, an in-memory array — fine for a demo, not for production). - Status codes are the API's vocabulary:
200(here it is),201(created),204(done, no body),400(bad input),404(not found). - Coerce
req.params.idto aNumber, and alwaysreturnafter sending a response so a handler doesn't try to respond twice. - The array is a database stand-in — handler shapes survive the swap to a real DB/ORM. See how an ORM works.
- The duplicated
404and400logic is a smell; Phase 6 centralizes it.
Quick check
[
{
"q": "Why coerce req.params.id with Number() before comparing it to a task's id?",
"choices": ["To make the URL shorter", "Route params arrive as strings, so '3' === 3 is false and the lookup would always miss", "Express requires all ids to be numbers", "It prevents SQL injection"],
"answer": 1,
"explain": "Route params are always strings. Without coercion, comparing the string '3' to the numeric id 3 is false, so every find() misses."
},
{
"q": "Which status code should a successful DELETE that returns no body use?",
"choices": ["200 OK", "201 Created", "204 No Content", "404 Not Found"],
"answer": 2,
"explain": "204 No Content means the action succeeded and there's nothing to send back — which is why delete uses res.sendStatus(204)."
},
{
"q": "What is the in-memory tasks array meant to represent in a real application?",
"choices": ["A permanent storage solution", "A cache layer in front of Redis", "A stand-in for a database that you swap for a real DB/ORM later", "A required part of every Express app"],
"answer": 2,
"explain": "The array is scaffolding. The handlers are written so that swapping it for a real database (often via an ORM) leaves their shape mostly unchanged."
}
]
← Phase 4: Request & Response · Guide overview · Phase 6: Error Handling →
Check your understanding
1. Why coerce req.params.id with Number() before comparing it to a task's id?
2. Which status code should a successful DELETE that returns no body use?
3. What is the in-memory tasks array meant to represent in a real application?