RESTful Routes & CRUD
A RESTful API models your application as a collection of resources — nouns like users, articles, or orders — and uses HTTP verbs to express the operations performed on them. Instead of inventing endpoint names like /getUser or /deleteArticle, you point every verb at a consistent URL and let GET, POST, PUT, PATCH, and DELETE carry the intent. This page builds the complete set of CRUD routes for a single resource and explains the semantics that separate a well-behaved API from one that merely works.
The standard route table
For any resource there is a canonical mapping between HTTP method, URL, and the CRUD operation it performs. Sticking to this table means clients can predict your API without reading documentation for every endpoint.
| Method | Path | Operation | Typical success status |
|---|---|---|---|
GET | /articles | List the collection | 200 OK |
POST | /articles | Create a new article | 201 Created |
GET | /articles/:id | Read one article | 200 OK |
PUT | /articles/:id | Replace an article fully | 200 OK |
PATCH | /articles/:id | Update an article partially | 200 OK |
DELETE | /articles/:id | Remove an article | 204 No Content |
Notice there are only two URLs — the collection (/articles) and the member (/articles/:id). The method, not the path, decides what happens.
Building the router
Group every route for the resource into a single express.Router() and mount it under the resource prefix. Using async handlers keeps database calls readable, and a try/catch forwards failures to your error middleware via next(err).
// routes/articles.js
const express = require("express");
const router = express.Router();
const db = require("../db");
// LIST → GET /articles
router.get("/", async (req, res, next) => {
try {
const articles = await db.articles.findAll();
res.json(articles);
} catch (err) {
next(err);
}
});
// CREATE → POST /articles
router.post("/", async (req, res, next) => {
try {
const { title, body } = req.body;
if (!title) return res.status(400).json({ error: "title is required" });
const article = await db.articles.create({ title, body });
res.status(201).location(`/articles/${article.id}`).json(article);
} catch (err) {
next(err);
}
});
// READ → GET /articles/:id
router.get("/:id", async (req, res, next) => {
try {
const article = await db.articles.findById(req.params.id);
if (!article) return res.status(404).json({ error: "Not found" });
res.json(article);
} catch (err) {
next(err);
}
});
module.exports = router;
Mount it in your entry file and enable the JSON body parser so req.body is populated:
// app.js
const express = require("express");
const app = express();
app.use(express.json());
app.use("/articles", require("./routes/articles"));
app.listen(3000, () => console.log("http://localhost:3000"));
A create request returns the new resource together with a Location header pointing at it:
Output:
$ curl -i -X POST http://localhost:3000/articles \
-H "Content-Type: application/json" \
-d '{"title":"REST in Express","body":"..."}'
HTTP/1.1 201 Created
Location: /articles/42
Content-Type: application/json
{"id":42,"title":"REST in Express","body":"..."}
Replacing vs. updating: PUT and PATCH
The two update verbs are not interchangeable, and confusing them is one of the most common REST mistakes. PUT replaces the entire resource with the representation in the request body — any field you omit is treated as cleared or reset, not left untouched. PATCH applies a partial change, modifying only the fields you send and leaving the rest as they are.
// REPLACE → PUT /articles/:id
router.put("/:id", async (req, res, next) => {
try {
const { title, body } = req.body;
if (!title) return res.status(400).json({ error: "title is required" });
// The full object is supplied; missing fields become null/default.
const article = await db.articles.replace(req.params.id, { title, body });
if (!article) return res.status(404).json({ error: "Not found" });
res.json(article);
} catch (err) {
next(err);
}
});
// UPDATE → PATCH /articles/:id
router.patch("/:id", async (req, res, next) => {
try {
// Only the provided keys are changed; everything else is preserved.
const article = await db.articles.update(req.params.id, req.body);
if (!article) return res.status(404).json({ error: "Not found" });
res.json(article);
} catch (err) {
next(err);
}
});
Gotcha: Because
PUTis a full replacement, sendingPUT /articles/42with only{ "title": "New" }should wipe out thebody. If your handler quietly preserves omitted fields, you have actually implementedPATCHsemantics under aPUTname — clients relying on replacement will be surprised.
Idempotency
An operation is idempotent when making the same request many times has the same effect on server state as making it once. This property lets clients safely retry after a dropped connection without fear of duplicating work.
| Method | Idempotent? | Why |
|---|---|---|
GET | Yes | Reads never change state. |
PUT | Yes | Replacing with the same body lands on the same final state. |
DELETE | Yes | Deleting an already-deleted resource leaves it deleted. |
PATCH | Not guaranteed | Depends on the patch (e.g. “increment by 1” is not idempotent). |
POST | No | Each call typically creates a new resource. |
The DELETE handler illustrates idempotency directly. Whether the article existed or not, repeated calls converge on the same outcome — the resource is gone.
// DELETE → DELETE /articles/:id
router.delete("/:id", async (req, res, next) => {
try {
await db.articles.remove(req.params.id);
res.status(204).end(); // 204 No Content — empty body
} catch (err) {
next(err);
}
});
Tip: Returning
204 No Contentfor a successfulDELETEkeeps it idempotent even when the row was already absent. Returning404on a second delete is also defensible, but many teams prefer the smoother204so retries never look like failures.
Express 5 note
In Express 5, route handlers that return a rejected promise are forwarded to your error-handling middleware automatically, so the try/catch wrappers above become optional. They remain necessary on Express 4.x. The CRUD routing model itself — collection and member URLs driven by HTTP verbs — is identical across both versions.
Best Practices
- Use plural nouns for collections (
/articles, not/article) and never put verbs in the path. - Return
201 Createdwith aLocationheader onPOST, and204 No ContentonDELETE. - Honor the contract: make
PUTa full replacement andPATCHa partial update — do not blur them. - Validate the request body up front and respond with
400 Bad Requestbefore touching the database. - Return
404 Not Foundfor member routes when the:iddoes not resolve, rather than a generic error. - Keep
PUTandDELETEidempotent so clients can retry safely on network failures. - Forward errors with
next(err)(Express 4) or rely on automatic promise rejection forwarding (Express 5) instead of handling each one inline.