Designing a REST API
REST (Representational State Transfer) is an architectural style for building APIs around resources that clients manipulate through a uniform set of HTTP operations. A well-designed REST API is predictable: once a developer learns how one resource behaves, they can guess how every other resource works. Express maps onto these ideas almost perfectly — its routing methods mirror HTTP verbs, and its middleware pipeline lets you enforce consistency across every endpoint. This page covers the constraints that define REST and the practical conventions for applying them in Express.
The REST constraints
REST is defined by a handful of constraints. You don’t have to memorize the academic list, but a few have direct, everyday consequences for how you write Express code:
- Client–server — the API exposes data and behavior; the client owns presentation. Your routes return JSON, not HTML.
- Stateless — each request carries everything the server needs to fulfill it. The server stores no per-client session in memory between requests.
- Uniform interface — resources are identified by URLs and acted on with standard verbs and status codes, so the API is self-describing.
- Cacheable — responses declare whether they can be cached, letting clients and proxies reuse them.
Model resources as nouns
The single most important design decision is choosing your resources. A resource is a thing your API exposes — a user, an order, an invoice. URLs should name these nouns, and the HTTP verb should express the action. Avoid verbs in paths (/getUser, /createOrder); the verb is already carried by the HTTP method.
Use plural nouns for collections and append an identifier for a single item. Nest paths only to express genuine containment.
| Bad | Good | Meaning |
|---|---|---|
GET /getAllUsers | GET /users | List users |
GET /user?id=5 | GET /users/5 | Fetch one user |
POST /createUser | POST /users | Create a user |
GET /users/5/getOrders | GET /users/5/orders | Orders belonging to user 5 |
In Express, a resource maps cleanly onto a router:
const express = require("express");
const router = express.Router();
router.get("/", listUsers); // GET /users
router.post("/", createUser); // POST /users
router.get("/:id", getUser); // GET /users/:id
router.put("/:id", replaceUser); // PUT /users/:id
router.patch("/:id", updateUser); // PATCH /users/:id
router.delete("/:id", deleteUser);// DELETE /users/:id
module.exports = router;
Map CRUD to HTTP verbs
Each verb has well-defined semantics. Respecting them is what makes an API RESTful rather than just “JSON over HTTP.” Two properties matter: safe methods never modify state, and idempotent methods produce the same result no matter how many times they’re repeated.
| Verb | CRUD | Idempotent | Typical success status |
|---|---|---|---|
GET | Read | Yes | 200 OK |
POST | Create | No | 201 Created |
PUT | Replace | Yes | 200 OK / 204 No Content |
PATCH | Partial update | No* | 200 OK |
DELETE | Delete | Yes | 204 No Content |
Tip:
PUTreplaces the entire resource;PATCHmodifies only the fields you send. SendingPUT /users/5with{ "name": "Ada" }should clear every other field. If that surprises your clients, you probably wantedPATCH.
Return the right status codes
Status codes are part of the contract — clients branch on them, so returning the correct one is not optional. Use the response’s class to signal the broad outcome and the specific code to add detail.
async function getUser(req, res, next) {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.status(200).json(user);
} catch (err) {
next(err); // delegate 5xx handling to the error middleware
}
}
async function createUser(req, res, next) {
try {
const user = await db.users.create(req.body);
res
.status(201)
.location(`/users/${user.id}`) // tell the client where the new resource lives
.json(user);
} catch (err) {
next(err);
}
}
The most common codes you’ll reach for:
| Code | Name | When to use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH |
201 | Created | Resource created by POST |
204 | No Content | Successful DELETE or update with no body |
400 | Bad Request | Malformed input or validation failure |
401 | Unauthorized | Missing or invalid credentials |
403 | Forbidden | Authenticated but not allowed |
404 | Not Found | Resource (or route) does not exist |
409 | Conflict | Duplicate or state conflict |
422 | Unprocessable Entity | Semantically invalid payload |
500 | Internal Server Error | Unhandled server fault |
Stay stateless
A stateless server treats every request independently — it never relies on data held in process memory from a previous call. Authentication state, for example, travels in a token on each request rather than in a server-side session object. This is what lets you run many identical Express instances behind a load balancer without sticky sessions.
// Each request authenticates itself via the Authorization header.
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Missing token" });
try {
req.user = verifyJwt(token); // derived per-request, not stored on the server
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
Keep responses consistent
Clients are far easier to write against an API whose responses share a shape. Decide on one envelope and use it everywhere — including errors. A common approach returns the resource directly for success and a structured error object for failures.
// Centralized error handler keeps every error response uniform
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({
error: {
message: err.expose ? err.message : "Internal Server Error",
status,
},
});
});
Output:
$ curl -i http://localhost:3000/users/999
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"User not found"}
Note: In Express 5, async route handlers that reject are forwarded to your error middleware automatically, so you can drop the
try/catchand barenext(err). In Express 4 you must catch rejections yourself or use a wrapper, or the request will hang.
Best Practices
- Name resources with plural nouns (
/orders,/invoices) and never put verbs in the path — the HTTP method is the verb. - Choose status codes deliberately:
201with aLocationheader on create,204on delete,4xxfor client mistakes,5xxonly for genuine server faults. - Honor verb semantics — keep
GETsafe, makePUTandDELETEidempotent, and reservePATCHfor partial updates. - Keep the server stateless: carry auth and context in each request (tokens, headers) rather than in server-side session memory.
- Standardize your response envelope, including a single structured error shape, and enforce it with one centralized error-handling middleware.
- Validate input at the edge and return
400/422with a clear message before touching your data layer.