Building RESTful APIs
1. Implementing Resource Routes
| HTTP | URL | Action | Status |
| GET | /users | List | 200 |
| GET | /users/:id | Read | 200 / 404 |
| POST | /users | Create | 201 + Location |
| PUT | /users/:id | Replace | 200 / 204 |
| PATCH | /users/:id | Partial update | 200 / 204 |
| DELETE | /users/:id | Remove | 204 |
Example: Resource router
const r = Router();
r.get("/", list);
r.post("/", create);
r.get("/:id", read);
r.put("/:id", replace);
r.patch("/:id", partialUpdate);
r.delete("/:id", remove);
app.use("/api/v1/users", r);
2. Using Proper HTTP Methods
| Verb | Use For |
| GET | Read-only, safe, idempotent — cacheable |
| POST | Create resource OR non-idempotent action |
| PUT | Replace entire resource (idempotent) |
| PATCH | Partial update — JSON Merge Patch / JSON Patch |
| DELETE | Remove resource (idempotent) |
3. Setting HTTP Status Codes
| Code | When |
| 200 OK | Successful read or update with body |
| 201 Created | POST created a resource (include Location) |
| 202 Accepted | Async work started |
| 204 No Content | Success without body (DELETE, PUT) |
| 301/308 | Permanent redirect |
| 400 Bad Request | Malformed body / params |
| 401 Unauthorized | Missing/invalid credentials |
| 403 Forbidden | Authenticated but not allowed |
| 404 Not Found | Resource doesn't exist |
| 409 Conflict | Version mismatch / duplicate |
| 422 Unprocessable Entity | Semantic validation failure |
| 429 Too Many Requests | Rate-limited |
| 500/502/503 | Server errors |
4. Structuring JSON Responses
Example: Envelope vs flat
// Flat
res.json(user);
// Envelope (preferred for collections)
res.json({
data: users,
meta: { page: 2, limit: 20, total: 137 },
links: { self: "/users?page=2", next: "/users?page=3", prev: "/users?page=1" }
});
// Errors
res.status(422).json({ error: { code: "VALIDATION", message: "...", details: [...] } });
5. Implementing Resource Naming
| Rule | Example |
| Plural nouns | /users not /user |
| Lowercase | /order-items |
| Hyphens, not underscores | /api/v1/order-items |
| No verbs | POST /users not /createUser |
| Sub-resources | /users/:id/posts |
| Filtering via query | /users?role=admin |
6. Using Nested Resources
Example: Nested router
const posts = Router({ mergeParams: true });
posts.get("/", (req, res) => res.json(getPosts(req.params.userId)));
posts.post("/", (req, res) => res.status(201).json(createPost(req.params.userId, req.body)));
const users = Router();
users.use("/:userId/posts", posts);
app.use("/api/v1/users", users);
Note: Limit nesting to 1–2 levels. Deeper hierarchies become unwieldy — flatten with query filters instead.
7. Implementing HATEOAS Links
res.json({
id: 42,
name: "Alice",
_links: {
self: { href: `/users/42` },
posts: { href: `/users/42/posts` },
avatar: { href: `/users/42/avatar` },
edit: { href: `/users/42`, method: "PATCH" },
"delete": { href: `/users/42`, method: "DELETE" }
}
});
8. Handling Bulk Operations
Example: Bulk create
app.post("/users/bulk", async (req, res) => {
const items = z.array(userSchema).max(100).parse(req.body);
const results = await Promise.allSettled(items.map(db.users.create));
const created = results.filter(r => r.status === "fulfilled").map(r => r.value);
const errors = results
.map((r, i) => r.status === "rejected" ? { index: i, error: r.reason.message } : null)
.filter(Boolean);
res.status(207).json({ created, errors }); // 207 Multi-Status
});
9. Using Proper Error Responses
Example: Consistent error shape
res.status(422).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid input",
details: [
{ field: "email", message: "Invalid format" },
{ field: "age", message: "Must be ≥ 18" }
],
requestId: req.id
}
});
10. Implementing Idempotent Operations
| Verb | Idempotent? | Implementation tip |
| PUT | Yes | Replace entire state |
| DELETE | Yes | Return 204 even if already gone |
| POST | No (default) | Use Idempotency-Key header to dedupe |
Example: Idempotency-Key
app.post("/payments", async (req, res) => {
const key = req.get("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
const existing = await cache.get(`idem:${key}`);
if (existing) return res.status(200).json(JSON.parse(existing));
const result = await processPayment(req.body);
await cache.set(`idem:${key}`, JSON.stringify(result), { EX: 86400 });
res.status(201).json(result);
});