Full-Stack Development · Tutorial 4 of 5
Build a REST API with Node and Express, step by step
By the FUTEAC Team · 2 November 2026 · 12 min read · Beginner
In short
Building a REST API with Express takes four moves: create an Express app, teach it routes (app.get, app.post, app.put, app.delete) that answer with JSON, read input from URL parameters and request bodies, and reply with the status code (200, 201, 400, 404). This tutorial builds a five-endpoint tasks API — about sixty lines — and tests every endpoint with curl.
- Express is routing plus conveniences on top of Node's HTTP server. Node can already serve HTTP; Express turns "if the method is GET and the path is /tasks" into one readable line.
- Routes are the API contract made executable. Each
app.METHOD(path, handler)line is one row of the endpoint table: what URL, what verb, what happens. - Input arrives in two places. URL parameters (
/tasks/3) name *which* resource; the JSON body (express.json()) carries the *data* for creates and updates. - Status codes are the verdict, JSON is the envelope. 200/201 for success, 400 for bad input, 404 for missing resources — the same codes from the REST explainer, now produced by your own code.
- This tutorial feeds the Node & Express API Foundations course (/course/node-express-api-foundations) and the Node.js (/skill/node-js) and Express / NestJS (/skill/express-nestjs) skills.
Before you start
What are Node and Express, in one paragraph each?
Node.js is JavaScript outside the browser. It runs the same language you use in pages, but with access to files, networks and processes — so it can *be* a server, not just talk to one. When you run node server.js, Node executes your file and keeps it alive, listening for network requests.
Express is a small library that makes writing that server pleasant. Node's built-in HTTP module can answer requests, but you end up hand-parsing URLs and methods with if statements (the previous tutorial showed exactly that). Express replaces the if forest with a routing table: app.get('/tasks', handler) means "when a GET request arrives for /tasks, run this function." Everything else in this tutorial is detail around that one idea.
What are we building?
A tasks API — the classic first backend, because it exercises every REST operation on one simple resource:
| Method | Endpoint | Job | Success code |
|---|---|---|---|
| GET | /tasks | list all tasks | 200 |
| GET | /tasks/:id | read one task | 200 (404 if missing) |
| POST | /tasks | create a task | 201 (400 if no title) |
| PUT | /tasks/:id | update a task | 200 (404 if missing) |
| DELETE | /tasks/:id | delete a task | 200 (404 if missing) |
The data lives in a plain JavaScript array in memory — it resets when the server restarts, which is *fine* for learning and is exactly why real APIs add databases (the subject of the Node course's next module). By the end, you will have made all five kinds of REST conversation happen against a server on your own machine.
Step 1 — Set up the project
Open a terminal, make a folder, and initialise it:
mkdir tasks-api && cd tasks-api
npm init -y
npm install expressnpm init -y creates a package.json with default answers. npm install express downloads Express into node_modules and records it as a dependency. At the time of writing, a plain npm install express installs Express 5.x (5.3.0 in the verified run below) — the code in this tutorial is written for the version you install, so do not pin an old express@4 from a dated blog post.
Expected result: a package.json listing express under dependencies, and no errors. You need Node.js installed — any recent version works; the run below used v24.22.1.
Step 2 — The server skeleton
Save this as server.js — it is the foundation everything else bolts onto:
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
app.listen(PORT, () => console.log(`API running at http://localhost:${PORT}`));Line by line:
require('express')loads the library;express()creates your application.app.use(express.json())is the line beginners most often forget. It installs middleware that reads incoming JSON request bodies and turns them intoreq.body. Without it,req.bodyisundefinedand every POST and PUT breaks — it appears in the mistakes section for a reason.app.listen(PORT, ...)starts the server and prints a confirmation. Your routes will be added between the middleware line and this line.
Step 3 — GET: read all and read one
Add the two read routes after the express.json() line:
// In-memory data (resets on restart — fine for a tutorial)
let tasks = [
{ id: 1, title: 'Learn Express routing', done: true },
{ id: 2, title: 'Build a REST API', done: false },
];
// READ all
app.get('/tasks', (req, res) => {
res.json(tasks);
});
// READ one
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});The patterns to absorb:
res.json(anything)sets the JSON content header and sends the body — the Express shortcut for what took several lines with raw Node.:idin the path is a route parameter. Express puts it inreq.params.id— as a *string*, always.Number(req.params.id)converts it, because1 === "1"is false and the lookup would 404 every time without the conversion.- The 404 branch
returns before the success line. Forgetting thereturnis a classic crash: Express would try to send two responses for one request.
Step 4 — POST: create, with validation
// CREATE
app.post('/tasks', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const newTask = {
id: tasks.length ? Math.max(...tasks.map(t => t.id)) + 1 : 1,
title,
done: false
};
tasks.push(newTask);
res.status(201).json(newTask);
});Three REST ideas in one route: the 201 Created status (not 200 — something new exists), 400 Bad Request when required input is missing (validate *before* touching data), and the server assigning the id (the client sent only a title; the response reveals the new resource, id included). The id line takes the highest existing id plus one, or 1 for an empty list — simple, predictable, good enough until a database takes over.
Step 5 — PUT: update
// UPDATE
app.put('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
if (req.body.title !== undefined) task.title = req.body.title;
if (req.body.done !== undefined) task.done = req.body.done;
res.json(task);
});The update merges only the fields the client sent — !== undefined checks (not truthiness checks) so that setting done: false or title: "" still works. It answers 200 with the updated task, the standard "here is what you now have" response.
Step 6 — DELETE: remove
// DELETE
app.delete('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
tasks = tasks.filter(t => t.id !== task.id);
res.json({ message: 'Task deleted' });
});filter builds a new array without the deleted task and reassigns it — which is why tasks is declared with let, not const. Deleting something that does not exist is a 404, same as reading it: the resource is addressed the same way for every method, exactly as REST prescribes.
The complete server.js is the six snippets above assembled in order: requires, app, express.json(), the data array, the five routes, then app.listen. Sixty lines, five endpoints.
Step 7 — Run it and test every endpoint with curl
Start the server:
node server.jsExpected output (this exact line):
API running at http://localhost:3000Leave that terminal running. Open a second terminal and talk to the API with curl. Every pair below is the actual output of a real run (Node.js v24.22.1, Express 5.3.0) — yours should match exactly.
List all tasks:
curl http://localhost:3000/tasks[{"id":1,"title":"Learn Express routing","done":true},{"id":2,"title":"Build a REST API","done":false}]
HTTP 200Read one task:
curl http://localhost:3000/tasks/1{"id":1,"title":"Learn Express routing","done":true}
HTTP 200Ask for what does not exist — the 404 arrives *with* a JSON body explaining it:
curl http://localhost:3000/tasks/99{"error":"Task not found"}
HTTP 404Create a task — -X POST sets the method, -H sets the JSON content header, -d sends the body:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Test with curl"}'{"id":3,"title":"Test with curl","done":false}
HTTP 201Create with missing input — validation fires before anything is stored:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{}'{"error":"Title is required"}
HTTP 400Update the new task:
curl -X PUT http://localhost:3000/tasks/3 \
-H "Content-Type: application/json" \
-d '{"done":true}'{"id":3,"title":"Test with curl","done":true}
HTTP 200Delete it:
curl -X DELETE http://localhost:3000/tasks/3{"message":"Task deleted"}
HTTP 200List again — the collection is back to the original two, proving the delete really removed it:
curl http://localhost:3000/tasks[{"id":1,"title":"Learn Express routing","done":true},{"id":2,"title":"Build a REST API","done":false}]
HTTP 200Eight requests, five endpoints, four status codes — the complete REST conversation, against a server you wrote.

What did Express actually do for you?
Compare with the raw-Node server from the previous tutorial: there, every route was an if on req.method and the URL, the body had to be assembled from data chunks by hand, and headers were set manually. Express absorbed all of that:
- Routing table instead of if-chains.
app.get('/tasks/:id', ...)declares the method, the path pattern and the handler in one line — and route parameters arrive parsed. - Body parsing as middleware. One
app.use(express.json())line handles every JSON body on every route, instead of chunk-assembly code in each handler. - Response helpers.
res.json()andres.status(n).json()replace manual header-setting and stringifying.
That is the honest pitch for Express: not magic, just the repetitive HTTP plumbing factored out so your code is the routes, the data and the rules.
Try it yourself
With the server running: 1. Add a PATCH route for partial updates (app.patch('/tasks/:id', ...)), reusing the PUT handler's merge logic. Verify with curl that {"title":"New title"} changes only the title. 2. Add a query-string filter: GET /tasks?done=true should return only completed tasks (read req.query.done, compare against the string "true"). This previews the "Route Parameters, Query Strings and JSON Bodies" lesson in the course. 3. Break it on purpose: comment out app.use(express.json()), restart, and POST a task. Watch req.body arrive as undefined and the 400 fire for the wrong reason — then restore the line. Deliberately causing the most common Express bug once means recognising it instantly forever.
Continue in the free course: Node & Express API Foundations →
Common mistakes
- Forgetting
app.use(express.json()).req.bodyisundefined, destructuring it crashes or every POST returns 400 for the wrong reason. The middleware line must come before the routes that use it. - Skipping
Number(req.params.id). Route parameters are strings;tasks.find(t => t.id === "1")never matches, so every:idlookup 404s. Convert, then compare. - Installing
express@4from an old tutorial. A plainnpm install expressnow gives you 5.x; pinning version 4 from a dated blog post buys you different behaviour in edge cases for no benefit. Install plainexpressand write for what you installed. - "Port 3000 is already in use" (EADDRINUSE). A previous
node server.jsis still running in another terminal. Stop it (Ctrl+C) or changePORT— do not stack servers on the same port and debug phantom responses. - Forgetting
returnafter sending an error. Without it, the handler keeps running and Express throws "Cannot set headers after they are sent" when the success line also responds. Send one response per request, then stop.
Frequently asked questions
Do I need to learn Express before I can use a REST API from the frontend?
No — using an API (fetch, from the previous tutorial) and building one (this tutorial) are separate skills, and frontend developers consume APIs daily without writing servers. But building one demystifies every error you will ever debug on the frontend side: you now know exactly what a 400 and a 404 mean *from the server's chair*.
Why does the data disappear when I restart the server?
The tasks live in a JavaScript array in memory — when the Node process exits, the array is gone. Real APIs persist to a database, which is the next module of the Node & Express API Foundations course ("Working with a Database": connecting Express to PostgreSQL and building full CRUD endpoints). In-memory storage is a feature for learning: nothing to install, nothing to corrupt.
Express 4 or Express 5 — does it matter for this tutorial?
npm install express currently installs 5.x, and every line here was run against 5.3.0. The routing, express.json() and response helpers used in this tutorial behave the same in both — but follow old tutorials' express@4 pins and you inherit edge-case differences with no upside. Install the current version.
Can I put this API on the internet?
Not as written — no authentication, no database, no rate limiting, and it listens on localhost only. Deploying safely is a later skill (the queue has a deployment troubleshooting tutorial coming). For now, localhost:3000 is exactly where a learning API belongs.
Where does this fit in the Full-Stack Developer roadmap?
It is the moment the roadmap's second half begins: you can now build the server your React frontend talks to. Next in the series is Authentication Basics — logins, sessions and protected routes — after which you have every piece of the roadmap's full-stack project: a working app with your own API. ---
Follow the Full-Stack Developer Roadmap
Build complete web applications — frontend, backend APIs, databases and deployment. Free, step by step, at your pace.
View the Roadmap