Skip to content
Express.js ex api 5 min read

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.

BadGoodMeaning
GET /getAllUsersGET /usersList users
GET /user?id=5GET /users/5Fetch one user
POST /createUserPOST /usersCreate a user
GET /users/5/getOrdersGET /users/5/ordersOrders 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.

VerbCRUDIdempotentTypical success status
GETReadYes200 OK
POSTCreateNo201 Created
PUTReplaceYes200 OK / 204 No Content
PATCHPartial updateNo*200 OK
DELETEDeleteYes204 No Content

Tip: PUT replaces the entire resource; PATCH modifies only the fields you send. Sending PUT /users/5 with { "name": "Ada" } should clear every other field. If that surprises your clients, you probably wanted PATCH.

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:

CodeNameWhen to use
200OKSuccessful GET, PUT, PATCH
201CreatedResource created by POST
204No ContentSuccessful DELETE or update with no body
400Bad RequestMalformed input or validation failure
401UnauthorizedMissing or invalid credentials
403ForbiddenAuthenticated but not allowed
404Not FoundResource (or route) does not exist
409ConflictDuplicate or state conflict
422Unprocessable EntitySemantically invalid payload
500Internal Server ErrorUnhandled 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/catch and bare next(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: 201 with a Location header on create, 204 on delete, 4xx for client mistakes, 5xx only for genuine server faults.
  • Honor verb semantics — keep GET safe, make PUT and DELETE idempotent, and reserve PATCH for 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/422 with a clear message before touching your data layer.
Last updated June 14, 2026
Was this helpful?