Node.js and Express course menu
Build a REST API with Node.js and Express (CRUD Example)
Design and build a complete REST API in Express: resources and URLs, CRUD with GET, POST, PUT, PATCH and DELETE, validation, filtering, sorting and pagination, consistent responses, and testing every endpoint with curl.
On this page
A REST API exposes resources (books, orders, users) at URLs and uses HTTP methods to act on them. This lesson builds a complete API for a book collection, keeping data in memory so the focus stays on API design; the next lesson stores it in a database.
Design first#
REST conventions:
- URLs name things (plural nouns:
/api/books), not actions (/api/getBooks); the method is the verb. - Nested resources show relationships:
/api/authors/3/books. - Filtering, sorting and paging use the query string:
/api/books?genre=fiction&sort=-year&page=2. - Responses use JSON and meaningful status codes.
The API#
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
let nextId = 4;
const books = [
{ id: 1, title: "Godan", author: "Premchand", year: 1936, genre: "fiction" },
{ id: 2, title: "Wings of Fire", author: "A. P. J. Abdul Kalam", year: 1999, genre: "biography" },
{ id: 3, title: "The Guide", author: "R. K. Narayan", year: 1958, genre: "fiction" },
];
const BookSchema = z.object({
title: z.string().trim().min(1).max(200),
author: z.string().trim().min(1).max(100),
year: z.number().int().min(1000).max(2100),
genre: z.enum(["fiction", "biography", "science", "history"]),
});
function parseOr400(schema, data, res) {
const result = schema.safeParse(data);
if (!result.success) {
res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
});
return null;
}
return result.data;
}
function findBook(req, res) {
const book = books.find((b) => b.id === Number(req.params.id));
if (!book) res.status(404).json({ error: "Book not found" });
return book;
}
// List with filter, sort and pagination
app.get("/api/books", (req, res) => {
const { genre, author, sort = "id", page = "1", limit = "10" } = req.query;
let result = books;
if (genre) result = result.filter((b) => b.genre === genre);
if (author) result = result.filter((b) => b.author.toLowerCase().includes(author.toLowerCase()));
const desc = sort.startsWith("-");
const field = desc ? sort.slice(1) : sort;
if (!["id", "title", "year"].includes(field)) return res.status(400).json({ error: "Cannot sort by " + field });
result = result.toSorted((a, b) => (a[field] > b[field] ? 1 : a[field] < b[field] ? -1 : 0) * (desc ? -1 : 1));
const pageNum = Math.max(1, Number(page) || 1);
const size = Math.min(50, Math.max(1, Number(limit) || 10));
const start = (pageNum - 1) * size;
res.json({ data: result.slice(start, start + size), page: pageNum, limit: size, total: result.length });
});
app.get("/api/books/:id", (req, res) => {
const book = findBook(req, res);
if (book) res.json({ data: book });
});
app.post("/api/books", (req, res) => {
const data = parseOr400(BookSchema, req.body, res);
if (!data) return;
const book = { id: nextId++, ...data };
books.push(book);
res.status(201).location(`/api/books/${book.id}`).json({ data: book });
});
app.put("/api/books/:id", (req, res) => {
const book = findBook(req, res);
if (!book) return;
const data = parseOr400(BookSchema, req.body, res); // PUT needs every field
if (!data) return;
Object.assign(book, data);
res.json({ data: book });
});
app.patch("/api/books/:id", (req, res) => {
const book = findBook(req, res);
if (!book) return;
const data = parseOr400(BookSchema.partial(), req.body, res); // PATCH: any subset of fields
if (!data) return;
Object.assign(book, data);
res.json({ data: book });
});
app.delete("/api/books/:id", (req, res) => {
const book = findBook(req, res);
if (!book) return;
books.splice(books.indexOf(book), 1);
res.sendStatus(204);
});
app.listen(3000);Trying every endpoint#
curl -s "localhost:3000/api/books?genre=fiction&sort=-year"
curl -s "localhost:3000/api/books?limit=2&page=2"
curl -s localhost:3000/api/books/2{"data":[{"id":3,"title":"The Guide","author":"R. K. Narayan","year":1958,"genre":"fiction"},{"id":1,"title":"Godan","author":"Premchand","year":1936,"genre":"fiction"}],"page":1,"limit":10,"total":2}
{"data":[{"id":3,"title":"The Guide","author":"R. K. Narayan","year":1958,"genre":"fiction"}],"page":2,"limit":2,"total":3}
{"data":{"id":2,"title":"Wings of Fire","author":"A. P. J. Abdul Kalam","year":1999,"genre":"biography"}}curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/books -H "Content-Type: application/json" -d '{"title":"A Brief History of Time","author":"Stephen Hawking","year":1998,"genre":"science"}'
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/books -H "Content-Type: application/json" -d '{"title":"","year":"1988","genre":"poetry"}'
curl -s -w " [%{http_code}]\n" -X PATCH localhost:3000/api/books/4 -H "Content-Type: application/json" -d '{"year":1988}'
curl -s -w " [%{http_code}]\n" -X PUT localhost:3000/api/books/4 -H "Content-Type: application/json" -d '{"year":1988}'
curl -s -w " [%{http_code}]\n" -X DELETE localhost:3000/api/books/4
curl -s -w " [%{http_code}]\n" localhost:3000/api/books/4{"data":{"id":4,"title":"A Brief History of Time","author":"Stephen Hawking","year":1998,"genre":"science"}} [201]
{"error":"Validation failed","details":["title: Too small: expected string to have >=1 characters","author: Invalid input: expected string, received undefined","year: Invalid input: expected number, received string","genre: Invalid option: expected one of \"fiction\"|\"biography\"|\"science\"|\"history\""]} [400]
{"data":{"id":4,"title":"A Brief History of Time","author":"Stephen Hawking","year":1988,"genre":"science"}} [200]
{"error":"Validation failed","details":["title: Invalid input: expected string, received undefined","author: Invalid input: expected string, received undefined","genre: Invalid option: expected one of \"fiction\"|\"biography\"|\"science\"|\"history\""]} [400]
[204]
{"error":"Book not found"} [404]The new book was created with a wrong year (1998). Notice the difference between PUT and PATCH when fixing it: PATCH changed only the year, while PUT with only a year was rejected because PUT replaces the whole resource, so every field is required. BookSchema.partial() makes all fields optional for PATCH.
Pagination metadata#
Returning page, limit and total alongside the data lets clients build page controls ("Page 2 of 5") without another request. Always cap limit (here at 50) so nobody can request a million rows at once.
Common mistakes#
Practice#
Practice exercise
Design (on paper) the endpoints for a library app where members borrow books: list a member's loans, borrow a book, and return it. Choose methods, paths and status codes, including what happens when a book is already borrowed.
Show hint
Treat a loan as its own resource. Returning a book can be modelled as updating the loan (PATCH) or deleting it.
Show solution
Keeping loans as records (rather than a flag on the book) preserves the borrowing history, and 409 Conflict clearly tells the client why borrowing failed.
Key takeaway#
Next: Using a Database (SQLite).
Finished this lesson?