Designing a REST API

Express · Practical · 14 min

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 OK with a body; 201 Created plus a Location header pointing to the new item; 204 No Content for successful deletes.
  • 400 invalid input, 401 not authenticated, 403 authenticated but not allowed, 404 not found, 409 conflict (duplicate), 422 semantically invalid, 429 rate-limited.
  • 500 only for your bugs; 503 when 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.

Check yourself

  1. A client retries a DELETE /items/5 after a timeout; the item is already gone. Which design is most RESTful?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/api/response