Building a REST API
CRUD endpoints with validation, status codes and error handling.
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.
200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 500 Server Error. Use the most accurate one.
Try it yourself
Add pagination to GET /tasks using ?page= and ?limit= (defaults 1 and 10, maximum limit 50) and include the total count in the response.
Show solution
app.get("/tasks", (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 10));
const start = (page - 1) * limit;
res.json({ total: tasks.length, page, limit, data: tasks.slice(start, start + limit) });
});