Express · Lesson 3 of 4
Building a REST API
CRUD endpoints with validation, status codes and error handling.
- Intermediate
- 16 min read
- 3 objectives
Before this lessonLesson 2: Routing and Middleware
What you will learn
- Design CRUD routes
- Return correct status codes
- Centralize error handling
A REST API models your data as resources at URLs, manipulated with HTTP methods. Designing it consistently makes it predictable for whoever consumes it. Here we build a complete CRUD API for tasks, using an in-memory array to keep the focus on the API design.
Route design
GET /tasks: list.GET /tasks/:id: one.POST /tasks: create (201).PATCH /tasks/:id: partial update.DELETE /tasks/:id: remove (204).- Use plural nouns for resources and let the HTTP method express the action.
import express from "express";
const app = express();
app.use(express.json());
let tasks = [];
let nextId = 1;
app.get("/tasks", (req, res) => {
const { done } = req.query;
const list = done === undefined ? tasks : tasks.filter((t) => String(t.done) === done);
res.json(list);
});
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);
});
app.post("/tasks", (req, res) => {
const { title } = req.body;
if (typeof title !== "string" || !title.trim()) {
return res.status(400).json({ error: "title is required" });
}
const task = { id: nextId++, title: title.trim(), done: false };
tasks.push(task);
res.status(201).location(`/tasks/${task.id}`).json(task);
});
app.patch("/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" });
Object.assign(task, req.body);
res.json(task);
});
app.delete("/tasks/:id", (req, res) => {
tasks = tasks.filter((t) => t.id !== Number(req.params.id));
res.status(204).end();
});Validation
Never trust request bodies. Validate with a schema library such as zod, which also gives clear error messages.
import { z } from "zod";
const TaskInput = z.object({
title: z.string().trim().min(1).max(100),
done: z.boolean().optional(),
});
app.post("/tasks", (req, res) => {
const parsed = TaskInput.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
// ...create using parsed.data
});Centralized error handling
An error-handling middleware has four parameters and goes after all routes. Errors passed to next(err) land there, so you handle them in one place.
app.use((req, res) => res.status(404).json({ error: "Route not found" }));
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: err.message || "Internal Server Error" });
});In Express 4, errors thrown inside async handlers are not caught automatically: wrap them with try/catch and call next(err). Express 5 handles rejected promises for you.
// Write your solution here
