REST API Design

Express.js Fundamentals — REST API Design

Express.js Fundamentals

Chapter 7 of 8  ·  REST API Design

REST API Design

REST is not a standard you pass or fail — it's a set of conventions that, when followed consistently, make an API predictable enough that a developer can guess how to call an endpoint they've never seen before. The goal is that GET /users returns a list, GET /users/5 returns user 5, POST /users creates one, and DELETE /users/5 removes it — all without reading documentation. That predictability is the entire point.

Resources, Not Actions

The core REST idea: URLs identify things (resources), and HTTP methods express what you want to do with them. The method IS the verb. Put a verb in the URL and you've written RPC dressed up as HTTP.

❌ RPC-style (verbs in URLs)

POST /getUser POST /createUser POST /deleteUser?id=5 POST /updateUserEmail GET /fetchAllProducts GET /searchProducts?q=x

✅ REST-style (nouns + HTTP methods)

GET /users/:id POST /users DELETE /users/:id PATCH /users/:id GET /products GET /products?q=x

URL Conventions

Plural nouns for collections

/users not /user

/products not /product

Consistent regardless of whether you expect one or many results.

Hierarchical sub-resources

/users/:id/orders — orders belonging to a user

/posts/:id/comments — comments on a post

Keep nesting shallow (max 2 levels). Deep nesting gets unwieldy.

Lowercase, hyphens

/user-profiles not /userProfiles or /user_profiles

URLs are case-sensitive on most servers. Lowercase avoids ambiguity.

No trailing slashes

/users not /users/

Either enforce no-slash (redirect or 301) or accept both — just pick one and be consistent.

HTTP Methods → CRUD

MethodURLActionIdempotent?Body?
GET /users List all users Yes No
GET /users/:id Get one user Yes No
POST /users Create a user No Yes
PUT /users/:id Replace a user entirely Yes Yes
PATCH /users/:id Update specific fields Usually Yes
DELETE /users/:id Remove a user Yes No

💡 PUT vs PATCH

PUT replaces the entire resource — if you omit a field, it's gone. A client must send all fields. PATCH is a partial update — only send the fields you want to change. In practice, most APIs implement PATCH (partial update) and label it PUT, or just use PATCH for everything. The distinction matters when clients need to update one field without risking data loss on others.

Status Codes That Matter

CodeNameWhen to use
200OKSuccessful GET, PATCH, PUT. General success with a body.
201CreatedPOST that created a resource. Include a Location header pointing at the new resource.
204No ContentSuccessful DELETE or PUT with no response body. Do NOT include a body.
400Bad RequestMalformed request — invalid JSON, wrong Content-Type, missing required query param.
401UnauthorizedNot authenticated (misleading name). The client needs to log in / send a token.
403ForbiddenAuthenticated but not allowed. The server knows who you are; you just can't do this.
404Not FoundResource doesn't exist. Also used for intentional "not exposing this resource exists" cases.
405Method Not AllowedThe URL exists but not for this HTTP method. Should include an Allow header.
409ConflictResource state conflict — duplicate email, version mismatch, concurrent edit.
422Unprocessable EntityParses fine but fails validation — email format wrong, value out of range.
429Too Many RequestsRate limited. Should include Retry-After header.
500Internal Server ErrorUnexpected server error. Never expose internal details in the response body.
503Service UnavailableTemporarily down — DB unavailable, maintenance mode. Include Retry-After if known.

⚠ 401 vs 403 — the Common Confusion

401 means "you haven't told me who you are yet" — send credentials and try again. 403 means "I know who you are and the answer is no." A logged-in user trying to edit someone else's post gets 403, not 401. A request with no auth token at all gets 401.

JSON Response Shape

Pick a shape and be consistent across every endpoint. Two common approaches:

Bare resource (simpler)

// Single resource { "id": 1, "name": "Alice" } // Collection [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ] // Simpler — works well for small APIs and // frameworks that expect bare arrays/objects.

Envelope (more flexible)

// Single resource { "data": { "id": 1, "name": "Alice" } } // Collection with pagination meta { "data": [ ... ], "meta": { "total": 142, "page": 1, "perPage": 20 } }

Error Response Shape

Errors must be consistent too. A client should never have to guess whether the error is in body.error, body.message, or body.errors[0].msg.

// Simple error (for most endpoints) { "error": "User not found", "status": 404 } // Validation error (multiple field errors) { "error": "Validation failed", "status": 422, "details": [ { "field": "email", "message": "must be a valid email" }, { "field": "password", "message": "must be at least 8 characters" } ] }

The Location Header on 201

When a POST creates a resource, tell the client where to find it. This is the REST-correct way — it avoids a follow-up GET to discover the new resource's URL.

app.post('/api/v1/users', async (req, res) => { const user = await db.createUser(req.body); res .status(201) .set('Location', `/api/v1/users/${user.id}`) .json({ data: user }); });

API Versioning

Versioning lets you change the API without breaking existing clients. The three common approaches:

StrategyExampleVerdict
URL versioning /api/v1/users Most common. Visible, cacheable, easy to test in a browser. Recommended default.
Accept header Accept: application/vnd.myapp.v2+json Technically correct REST. Harder to test, invisible in browser. Used by GitHub API.
Query parameter /users?version=1 Easy to add, but query params are for filtering/sorting, not protocol selection. Avoid.
// URL versioning in Express — mount routers under a version prefix const v1 = express.Router(); const v2 = express.Router(); v1.use('/users', usersRouterV1); v1.use('/products', productsRouterV1); v2.use('/users', usersRouterV2); // different behaviour for v2 v2.use('/products', productsRouterV1); // can reuse unchanged routers app.use('/api/v1', v1); app.use('/api/v2', v2); // When to version: breaking changes only // Breaking: removing a field, renaming a field, changing a field's type // Non-breaking: adding a new field, adding a new endpoint, relaxing validation

Filtering, Sorting, and Pagination

// Filtering — filter fields as query params // GET /products?category=electronics&inStock=true&minPrice=50 // Sorting — sort + order query params // GET /products?sort=price&order=asc // GET /products?sort=createdAt&order=desc // Pagination — page + limit (offset pagination) // GET /products?page=2&limit=20 // Server: LIMIT 20 OFFSET (page - 1) * limit app.get('/api/v1/products', async (req, res) => { const page = Math.max(1, Number(req.query.page) || 1); const limit = Math.min(100, Number(req.query.limit) || 20); const offset = (page - 1) * limit; const VALID_SORTS = new Set(['price', 'name', 'createdAt']); const sort = VALID_SORTS.has(req.query.sort) ? req.query.sort : 'createdAt'; const order = req.query.order === 'asc' ? 'asc' : 'desc'; const { rows: data, count: total } = await db.findProducts({ sort, order, limit, offset }); res.json({ data, meta: { total, page, perPage: limit, pages: Math.ceil(total / limit) }, }); });

💡 Always Allowlist Sort Fields

Never do ORDER BY ${req.query.sort} directly — that's SQL injection. Use a Set or object map of permitted field names and fall back to a default if the client sends something unexpected. Same applies to order: only accept 'asc' or 'desc', nothing else.

A Complete REST Router

// routes/v1/users.js — conventional CRUD endpoint layout const router = express.Router(); // Collection endpoints router.route('/') .get(async (req, res) => { // GET /api/v1/users const page = Math.max(1, Number(req.query.page) || 1); const limit = Math.min(50, Number(req.query.limit) || 10); const { rows, count } = await db.findUsers({ limit, offset: (page - 1) * limit }); res.json({ data: rows, meta: { total: count, page, perPage: limit } }); }) .post(async (req, res) => { // POST /api/v1/users const user = await db.createUser(req.body); res.status(201) .set('Location', `/api/v1/users/${user.id}`) .json({ data: user }); }); // Single-resource endpoints router.route('/:id') .get(async (req, res) => { // GET /api/v1/users/:id const user = await db.findById(req.params.id); if (!user) throw createError(404, `User ${req.params.id} not found`); res.json({ data: user }); }) .patch(async (req, res) => { // PATCH /api/v1/users/:id const updated = await db.updateUser(req.params.id, req.body); if (!updated) throw createError(404, `User ${req.params.id} not found`); res.json({ data: updated }); }) .delete(async (req, res) => { // DELETE /api/v1/users/:id const existed = await db.deleteUser(req.params.id); if (!existed) throw createError(404, `User ${req.params.id} not found`); res.status(204).end(); }); module.exports = router;

Coding Challenges

Challenge 1 — RESTful Products CRUD

Build a /api/v1/products router with a full REST surface on an in-memory array. GET / lists all products (bare array, no envelope needed yet). POST / creates a product (validate name and price — throw 422 with a details array listing each failing field); respond 201 with a Location header. GET /:id fetches one — 404 if missing. PATCH /:id accepts partial updates (name and/or price); merge into existing resource, 404 if missing. DELETE /:id removes it — 204 No Content on success, 404 if missing. Mount the router at /api/v1/products. Use express-async-errors. Write supertest tests: each status code, the 201 Location header, the 422 details array for multiple missing fields, and a 204 with no body.

View sample solution ↗

Challenge 2 — Filtering, Sorting & Pagination

Extend the products router with query-param support on GET /api/v1/products. Filtering: ?category= (exact match), ?minPrice= and ?maxPrice= (numeric, validate they're positive numbers). Sorting: ?sort=name|price|id (allowlist — default id), ?order=asc|desc (default asc). Pagination: ?page= (min 1, default 1), ?limit= (min 1, max 50, default 10). Response shape: { data: [...], meta: { total, page, perPage, pages } }. Pre-load the in-memory store with at least 25 products across 3 categories and varying prices. Write supertest tests: filtering by category reduces the list; minPrice/maxPrice filter correctly; sort=price asc puts cheapest first; page=2&limit=5 returns the right slice and correct meta.pages; an invalid sort field is silently ignored (falls back to default); minPrice greater than maxPrice returns 400.

View sample solution ↗

Challenge 3 — API Versioning & Consistent Error Shape

Build an app with two API versions. Mount identical route logic for /api/v1/articles and /api/v2/articles, but with a breaking change in v2: the response envelope wraps the resource in { data: ... } whereas v1 returns the bare resource/array. Implement shared router logic in a factory function createArticlesRouter({ wrap }) where wrap is a boolean — when true, wrap single resources in { data: ... } and collections in { data: [...], meta: { total } }; when false, return bare. Both versions support GET /, POST /, GET /:id, DELETE /:id. Add a centralized error handler that always returns { error, status } JSON regardless of version. Write supertest tests: v1 GET returns a bare array; v2 GET returns { data: [...], meta }; v1 POST returns bare resource with 201; v2 POST returns { data: ... } with 201; both versions return the same error shape for 404 and 422; the 204 DELETE has no body.

View sample solution ↗