Learn / Frameworks / Express / Building a REST API

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
Up next · Lesson 4Databases and AuthenticationPersist data, hash passwords and protect routes with JWT.