A predictable API is easier to consume, document and evolve without breaking clients.
REST conventions give clients a predictable interface. The core ideas:
Resources are nouns, methods are verbs. /articles is a collection, /articles/42 one item. Use GET to read, POST to create, PUT to replace, PATCH to partially update, DELETE to remove. Avoid verbs in paths like /getArticles or /deleteArticle.
Status codes carry meaning:
200 OKwith a body;201 Createdplus aLocationheader pointing to the new item;204 No Contentfor successful deletes.400invalid input,401not authenticated,403authenticated but not allowed,404not found,409conflict (duplicate),422semantically invalid,429rate-limited.500only for your bugs;503when a dependency is down.
Idempotency. GET, PUT and DELETE must be safe to retry; POST isn't. For payments and other critical creates, accept an Idempotency-Key header and return the stored result on retries.
Collections should paginate: ?limit=20&cursor=... (cursor-based scales better than ?page=), return the next cursor, and cap limit. Filtering and sorting go in the query string: ?status=open&sort=-createdAt.
One error shape everywhere, e.g. { "error": { "code": "not_found", "message": "..." } } or the RFC 9457 problem-details format. Clients can then handle errors generically.
Evolve without breaking: add fields freely, never rename or remove them silently. When you must break, version with a path prefix (/v2) or a header.
Document the API with OpenAPI; generated docs and client SDKs pay for themselves quickly.
Example
import express from "express";
const app = express();
app.use(express.json());
const articles = new Map();
let nextId = 1;
app.get("/v1/articles", (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100); // cap page size
const after = Number(req.query.cursor) || 0;
const page = [...articles.values()].filter((a) => a.id > after).slice(0, limit);
res.json({ data: page, nextCursor: page.length === limit ? page.at(-1).id : null });
});
app.post("/v1/articles", (req, res) => {
const article = { id: nextId++, title: String(req.body?.title ?? "untitled") };
articles.set(article.id, article);
res.status(201).location(`/v1/articles/${article.id}`).json(article);
});
app.patch("/v1/articles/:id", (req, res) => {
const article = articles.get(Number(req.params.id));
if (!article) return res.status(404).json({ error: { code: "not_found", message: "Article not found" } });
if (typeof req.body?.title === "string") article.title = req.body.title;
res.json(article);
});
app.delete("/v1/articles/:id", (req, res) => { articles.delete(Number(req.params.id)); res.status(204).end(); });
app.listen(3000);Remember
- Nouns for resources, HTTP methods for actions
- 201 + Location on create, 204 on delete, precise 4xx codes
- Paginate collections (prefer cursors) and cap page size
- One consistent error format; evolve additively
Watch out for
- Returning 200 with
{ success: false }for errors, which defeats HTTP-level handling.