CRUD Operations with Express
CRUD — create, read, update, delete — is the shape of most of the work an API does. Building a REST API mostly means mapping those four onto HTTP methods and writing the routes below, and once you have written them properly for books you have written them for every resource you will build afterwards.
The example stores books in an ordinary array so that routing and status codes are the only new things to think about. That is a deliberate scaffold and it is also a lie: the array lives in the memory of one process, so it vanishes on restart and is not shared between two copies of your server. A later section in this lesson explains precisely how that fails, but for now it keeps the code readable.
Read the status codes as carefully as the logic, because they carry the meaning. A successful list is 200 with an array — an empty array when there is nothing, not a 404, because the collection exists and simply happens to be empty. A single missing book is a 404. A successful create is 201. A successful delete is 204 with no body at all.
Notice the parseInt in front of every lookup. req.params.id is the string '42', so books.find(b => b.id === req.params.id) compares a number to a string with === and is always false. Every single lookup then returns 404 and the routing appears broken while being entirely correct. This is the most common bug in a first CRUD API, and it wastes an evening the first time.
The PUT handler replaces the whole book, which is why it insists on both fields being present. If you would rather accept a partial update, that is PATCH, and it needs different validation: check the fields that were sent and leave the rest alone. Writing PUT and treating it like PATCH is how an author's name silently disappears from a record.
const express = require('express');
const app = express();
app.use(express.json());
// In-memory data store
let books = [
{ id: 1, title: '1984', author: 'George Orwell' },
{ id: 2, title: 'Dune', author: 'Frank Herbert' }
];
let nextId = 3;
// GET all books
app.get('/api/books', (req, res) => {
res.json(books);
});
// GET a single book by ID
app.get('/api/books/:id', (req, res) => {
// parseInt matters: req.params.id is '42', and 42 === '42' is false
const book = books.find(b => b.id === parseInt(req.params.id, 10));
if (!book) return res.status(404).json({ error: 'Book not found' });
res.json(book);
});
// POST — Create a new book
app.post('/api/books', (req, res) => {
const { title, author } = req.body; // pick fields; never spread req.body
if (typeof title !== 'string' || typeof author !== 'string') {
return res.status(400).json({ error: 'Title and author are required' });
}
const book = { id: nextId++, title: title.trim(), author: author.trim() };
books.push(book);
res.status(201).location(`/api/books/${book.id}`).json(book);
});
// PUT — Update a book
app.put('/api/books/:id', (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return res.status(404).json({ error: 'Book not found' });
const { title, author } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
book.title = title;
book.author = author;
res.json(book);
});
// DELETE — Remove a book
app.delete('/api/books/:id', (req, res) => {
const index = books.findIndex(b => b.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Book not found' });
books.splice(index, 1);
res.status(204).send();
});
app.listen(3000, () => console.log('API running on port 3000')); GET /api/books— the collection; an empty array, never a 404GET /api/books/:id— one book, or 404POST /api/books— 201, with aLocationheader where you canPUT /api/books/:id— a full replacement, so every field is requiredPATCH /api/books/:id— a partial update; validate only what was sentDELETE /api/books/:id— 204, and no bodyparseInt(req.params.id, 10)— the string-versus-number bug lives here
- An empty collection is a success, not an error.
GET /api/bookswith no books returns 200 and[]. Returning 404 there forces every client to write a special case, and it breaks the entirely reasonable assumption that a 200 response body can be handed straight to.map().
Never Trust req.body
express.json() gives you an object. It does not give you a valid object. Everything in req.body arrived from outside your program, and "outside" includes a mistyped frontend, a half-finished mobile app, and somebody deliberately poking at your API with curl. Validation is not defensive politeness; it is the boundary between your program and the internet.
Check types, not just truthiness. if (!title) rejects an empty string and cheerfully accepts title: 12345, title: {} and title: ['a','b'] — each of which surfaces later as a strange error in a place with no obvious connection to the request that caused it. typeof title !== 'string' is barely longer and rules all of that out at the door.
The dangerous mistake is quieter. const book = { id: nextId++, ...req.body } looks convenient and lets a client send whatever it likes, including fields you never designed for. On a user model that becomes { email, password, role: 'admin' }, and your registration endpoint now creates administrators to order. This is called mass assignment, and the fix is to name the fields you accept, one at a time, and ignore everything else.
Normalise as well as check. Trim strings, so a title made of three spaces is not accepted. Lowercase email addresses, so two people cannot register the same address in different cases and then argue about it. Impose a maximum length, because nothing prevents a client from sending a two-megabyte title, and your database will either dutifully store it or fail in an ugly way at an awkward moment.
Write these checks by hand while you are learning, so that you understand what a validation library does for you. Then use one — zod, joi or express-validator — for anything real. A schema declared once gives you type checks, defaults, length limits and a list of errors per field, and it stays readable when the object has fifteen properties instead of three.
const express = require('express');
const app = express();
app.use(express.json());
// Validation by hand — one entry per problem, each naming its field
function validateBook(body) {
const errors = [];
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title is required' });
} else if (body.title.length > 200) {
errors.push({ field: 'title', message: 'Title is too long (max 200)' });
}
if (typeof body.author !== 'string' || body.author.trim().length === 0) {
errors.push({ field: 'author', message: 'Author is required' });
}
if (body.year !== undefined) {
if (!Number.isInteger(body.year) || body.year < 0 || body.year > 2100) {
errors.push({ field: 'year', message: 'Year must be a sensible integer' });
}
}
return errors;
}
app.post('/api/books', (req, res) => {
const errors = validateBook(req.body);
if (errors.length > 0) {
return res.status(400).json({ errors });
}
// Name every field you accept. NEVER { ...req.body } — that lets a client
// send id, role, isAdmin or anything else you never designed for.
const book = {
id: crypto.randomUUID(), // Date.now() collides for two creates in one ms
title: req.body.title.trim(),
author: req.body.author.trim(),
year: req.body.year ?? null
};
res.status(201).json(book);
});
// Test with curl:
// curl -X POST http://localhost:3000/api/books \
// -H "Content-Type: application/json" \
// -d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}' - Check types, not truthiness —
!titleaccepts numbers, objects and arrays - Name every field you accept; never build a record with
{ ...req.body } - Mass assignment: a spread body is how a client quietly sets
role: 'admin' - Trim strings, lowercase emails, and cap lengths before storing anything
- Return one error per field so the frontend can highlight all of them at once
- Write it by hand once to understand it, then use a schema library for real work
- Validate on the server even when the frontend already validates. Browser checks are a convenience for honest users and nothing more — anybody can send a request without ever loading your form, and every serious attack does exactly that. The rule holds even when the frontend is yours and you trust it completely.
Splitting the Route File into Layers
The file you have just written works, and it will not survive growth. Add authentication, pagination, a second resource and proper error handling, and it becomes six hundred lines in which the HTTP concerns, the rules of your application and the storage details are all tangled into the same functions.
The split most Node projects settle on has three layers, and it is worth applying now rather than after the tangle forms. The route knows about HTTP: it reads the request, calls something, chooses a status code. The service knows the rules of your application: a book must have an author, a task belongs to a user, an order cannot be cancelled after dispatch. The model or repository knows how things are stored.
There is a simple test of whether you have done it honestly. Could you call your service functions from a small command-line script, with no Express running at all? If yes, the layers are real. If a service reads req.body or calls res.status, it is a route handler that has merely been moved into a different file.
The payoff arrives in the very next lesson, when the array is replaced by MongoDB. If the storage details live in one module, that change touches one file and every route keeps working unmodified. If books.find(...) is scattered across eight handlers, it is an afternoon of edits and a good chance of missing one of them.
Do not over-build, though. For a project with one resource and five routes, a route file and a service file are plenty; adding a repository, a data-transfer object and an interface for each is ceremony that helps nobody and hides the actual logic. Start with a split that matches the size of the problem and let the structure grow when the pain does.
// services/bookService.js — knows the rules, knows nothing about HTTP
let books = [];
let nextId = 1;
function listBooks({ author } = {}) {
return author ? books.filter(b => b.author === author) : books;
}
function getBook(id) {
return books.find(b => b.id === id) || null;
}
function createBook({ title, author, year }) {
const book = { id: nextId++, title, author, year: year ?? null };
books.push(book);
return book;
}
function deleteBook(id) {
const i = books.findIndex(b => b.id === id);
if (i === -1) return false;
books.splice(i, 1);
return true;
}
module.exports = { listBooks, getBook, createBook, deleteBook };
// routes/books.js — knows HTTP, knows nothing about storage
const express = require('express');
const service = require('../services/bookService');
const router = express.Router();
router.get('/', (req, res) => {
res.json(service.listBooks({ author: req.query.author }));
});
router.get('/:id', (req, res) => {
const book = service.getBook(Number(req.params.id));
if (!book) return res.status(404).json({ error: 'Book not found' });
res.json(book);
});
router.post('/', (req, res) => {
const errors = validateBook(req.body); // validate at the edge
if (errors.length) return res.status(400).json({ errors });
const book = service.createBook({ // named fields only
title: req.body.title.trim(),
author: req.body.author.trim(),
year: req.body.year
});
res.status(201).location(`/api/books/${book.id}`).json(book);
});
router.delete('/:id', (req, res) => {
const removed = service.deleteBook(Number(req.params.id));
if (!removed) return res.status(404).json({ error: 'Book not found' });
res.status(204).send();
});
module.exports = router; - Routes handle HTTP; services hold the rules; models handle storage
- A service that mentions
reqorresis a route handler in disguise - The test: could you call the service from a script with no Express running?
- Swapping the storage should touch one file, not eight route handlers
- Validate at the edge, in the route, before the service is called
- Match the structure to the size of the problem; do not build layers you do not need
- Keep validation in the route rather than the service. The route is the boundary with the outside world, so that is where untrusted input should stop being untrusted. A service that re-checks everything it is handed ends up duplicating the same conditions in two places, and sooner or later the two copies disagree with each other.
Why the In-Memory Array Has to Go
The array was a scaffold, and it is worth one section on exactly how it fails — because those failures are the reason databases exist, and none of them are visible from reading the code.
It disappears on restart. Every deployment, every crash, and every reload that node --watch performs when you save a file empties it. In development that is merely irritating. In a demo, where a reviewer's carefully entered data vanishes because you saved a file in the other window, it is a different kind of irritating.
It is not shared. A Node process has its own memory, so the moment you run two copies for capacity — which is the normal way to use more than one processor core — a book created on one is invisible to the other, and requests bounce between them. The symptom is data that appears and disappears depending on which instance answered, and it is genuinely hard to diagnose unless you already suspect it.
It has none of the safety a database provides around concurrent writes. nextId++ is fine while the handler is entirely synchronous, but the moment the create path contains an await, two requests can interleave between reading the array and writing to it. Databases exist partly to answer that question properly, and hand-rolled versions of the answer are usually subtly wrong in ways that only show up under load.
And it grows without limit. Nothing evicts anything, so a long-running process holding uploads or session data in a module-level object leaks memory until something kills it. If you genuinely need an in-process cache, use one with a size cap and an expiry — but anything a user expects to still exist tomorrow belongs in a database, which is the next two lessons.
// Everything below is true of this one innocent line:
let books = [];
// 1. A restart-shaped hole
// node --watch reloads when you save -> books is [] again
// 2. Two instances, two separate arrays
// running two copies for capacity -> a book created on instance A
// is a 404 on instance B, intermittently, depending on who answered
// 3. Interleaving, once the handler becomes async
app.post('/api/books', async (req, res) => {
const id = nextId++; // read
await somethingSlow(); // <- another request runs right here
books.push({ id, ...bookFields }); // write
});
// 4. Nothing ever removes anything
// uploads, sessions, cached results -> memory grows until the process dies
// What replaces it, in the next two lessons:
// const book = await Book.create({ title, author }); // MongoDB
// await pool.query('INSERT INTO books (title) VALUES (?)', [title]); // SQL - Restart, crash, or a file save under
--watch— the data is gone - Two processes mean two separate arrays; records appear and vanish at random
nextId++stops being safe as soon as the handler contains anawait- Nothing evicts anything — an in-memory store grows until the process is killed
- Module-level state is shared across requests: never keep per-user data there
- Fine for a demo, a test fixture or a bounded cache; never for real data
- There is one legitimate use for module-level state: a cache you can afford to lose, with a maximum size and an expiry time. The question that separates the two cases is simple — if this process restarts right now, does anybody notice? If the answer is yes, it belongs in a database.
Testing Your API by Hand
You cannot test an API from the browser address bar, because typing a URL only ever sends a GET. Learn one tool properly instead. curl is present on nearly every machine and works inside scripts; Postman, or the REST Client extension in VS Code, is more comfortable once requests need headers and bodies. Use whichever you prefer, but be able to read a raw HTTP response.
curl -i prints the status line and headers as well as the body, and those are the parts you are actually testing. HTTP/1.1 201 Created with a Location header tells you the create route is correct. A 200 where you expected 201 is a bug, even though the book was created and the body looks perfect.
Two mistakes make a working POST look broken. Forgetting -H "Content-Type: application/json", so express.json() declines to parse the body and req.body is empty. And quoting: on Windows, single quotes around JSON are not what the shell expects, and the resulting error talks about JSON rather than about quoting. If a request works for a classmate and not for you, compare shells before you compare code.
Walk every endpoint through four cases rather than one. The happy path. The missing resource — does GET /api/books/9999 answer 404 rather than crashing? The invalid input — does a POST with no title give a 400 with a useful message, rather than a 500? And the strange input — a string where a number belongs, an id of abc, an enormous body. Ten minutes of that finds more than an hour of re-reading your own code.
Save the requests. A Postman collection, or a file of curl commands committed to the repository, documents the API, shows the next person how to try it, and is the thing you run before declaring a change finished. It is also the natural first step towards automated tests, which are these same requests with the answers checked by a machine instead of by you.
# Read the STATUS LINE and headers, not just the body
curl -i http://localhost:3000/api/books
# Create — the Content-Type header is mandatory, or req.body arrives empty
curl -i -X POST http://localhost:3000/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Dune","author":"Frank Herbert","year":1965}'
# expect: HTTP/1.1 201 Created + Location: /api/books/3
# Update, then delete
curl -i -X PATCH http://localhost:3000/api/books/3 \
-H "Content-Type: application/json" -d '{"year":1966}'
curl -i -X DELETE http://localhost:3000/api/books/3
# expect: HTTP/1.1 204 No Content, and no body at all
# Now the cases that actually find bugs:
curl -i http://localhost:3000/api/books/9999 # expect 404, not a crash
curl -i http://localhost:3000/api/books/abc # expect 400, not a 500
curl -i -X POST http://localhost:3000/api/books \
-H "Content-Type: application/json" -d '{}' # expect 400
curl -i -X POST http://localhost:3000/api/books \
-H "Content-Type: application/json" -d '{"title":123}' # expect 400
# Quoting differs by shell. On Windows, run these from Git Bash,
# or use Postman rather than fighting PowerShell's quoting rules. curl -i— read the status line and headers, not only the body-H "Content-Type: application/json"— without it,req.bodyis empty- Four cases per endpoint: happy path, missing, invalid, and deliberately strange
- A 200 where you expected 201 is a bug even though the record was created
- Quoting differs between shells — that is why it works for your classmate and not you
- Commit the requests: they are documentation and the seed of real tests
- Before calling an endpoint finished, send it something deliberately wrong. A 500 with a stack trace in the response means input reached code that did not expect it. Finding that yourself takes two minutes; finding it because a user reported it takes considerably longer and is a great deal less pleasant.
