Skip to main content

REST API design principles and best practices?

REST API design is a set of conventions for building web services where data is exposed as addressable resources at predictable URLs, and HTTP methods define what you do with them.

Theory

TL;DR

  • Think of it like a library catalog: each book (resource) has a fixed shelf location (URL), and you borrow or return it using standard actions (GET/POST), no special instructions needed.
  • Core rule: nouns in paths (/users), verbs via HTTP (GET lists, POST creates, DELETE removes).
  • GET /users/123 reads a user. DELETE /users/123 removes it. The URL never changes, only the method.
  • Use REST for public APIs where caching and predictability matter. Use GraphQL when clients need flexible field selection.
  • Stateless means every request carries its full context. No server-side sessions.

Quick example

javascript
const express = require('express'); const app = express(); app.use(express.json()); let users = [{ id: 1, name: 'Alice' }]; app.get('/users', (req, res) => res.json(users)); // 200 [{id:1,name:'Alice'}] app.post('/users', (req, res) => { // 201 {id:2,name:'Bob'} const user = { id: users.length + 1, ...req.body }; users.push(user); res.status(201).set('Location', `/users/${user.id}`).json(user); }); app.get('/users/:id', (req, res) => { // 200 or 404 const user = users.find(u => u.id === +req.params.id); user ? res.json(user) : res.status(404).json({ error: 'Not found' }); }); app.delete('/users/:id', (req, res) => { // 204 No Content users = users.filter(u => u.id !== +req.params.id); res.status(204).send(); }); app.listen(3000);

POST returns 201 Created with a Location header pointing to the new resource. DELETE returns 204 with no body. These are not optional conventions, they are what HTTP clients and CDNs expect.

Key difference from RPC-style APIs

RPC-style APIs use verb-heavy paths: /getUserById, /createUser, /deleteUser. REST treats the URL as a noun and the HTTP method as the verb. /users/123 is the resource. GET, PUT, and DELETE are the actions. This separation means caching infrastructure (CDNs, reverse proxies) can make decisions based on the method alone, without parsing the URL for intent.

When to use REST

  • Public consumer API - REST. Predictable, cacheable, works with any HTTP client.
  • Internal microservice with complex contracts - REST or gRPC. REST if HTTP tooling is enough.
  • Client needs flexible field selection - GraphQL. Avoids sending 40 fields when you need 3.
  • Real-time push updates - WebSockets or SSE. REST has no push by design.
  • Simple CRUD - REST. No learning curve, works immediately.

How HTTP handles REST requests

The server (Node.js, Nginx, or whatever sits in front) parses incoming requests by matching the URL path to a route handler and dispatches based on req.method. No server-side session is stored. Each request carries its full context: auth token in Authorization, filters in query params, body in the JSON payload.

GET responses can be cached by CDNs using ETag and Last-Modified headers. When a client sends If-None-Match with the stored ETag, the server returns 304 Not Modified with no body if nothing changed. That is free performance with zero extra code.

Common mistakes

Verb paths. /getUsers or /deleteUser/123 breaks caching and confuses HTTP-aware tools.

javascript
// Wrong app.get('/getAllUsers', handler); app.get('/deleteUser/:id', handler); // Right app.get('/users', handler); app.delete('/users/:id', handler);

Singular nouns. /user instead of /users creates ambiguity. POST /user - does that create a new user or refer to the current one? Always use plural.

Wrong status codes on create. Returning 200 when a resource is created misleads clients and automated tools.

javascript
// Wrong res.status(200).json(newUser); // Right res.status(201).set('Location', `/users/${newUser.id}`).json(newUser);

No pagination on list endpoints. A GET /users that returns every record will fail on any real dataset. Default to ?page=1&limit=20.

Stateful sessions. Storing session data server-side violates statelessness and makes horizontal scaling harder. Use JWT in Authorization: Bearer <token> instead.

Confusing PUT and PATCH. PUT replaces the entire resource. If you send only { name: 'Bob' } to PUT /users/1 and the handler does a full replace, the user's email disappears. PATCH is for partial updates. Many teams default to PATCH for edit operations and reserve PUT for cases where a full replacement is intentional.

Real-world usage

  • Stripe API - /v1/customers, GET lists with cursor pagination, POST creates with idempotency keys in headers.
  • GitHub API - /repos/{owner}/{repo}/issues, HATEOAS-style _links for pagination in responses.
  • Twitter API v2 - /2/tweets?expansions=author_id, query params for field selection.
  • Express + Prisma - plural nouns, HTTP methods, prisma.user.findMany() maps directly to GET /users.
  • Versioning in production - URL-based (/api/v1/users) is simpler for proxy configuration. Header-based (Accept: application/vnd.myapi.v2+json) is cleaner but harder to test in a browser.

Follow-up questions

Q: Why plural nouns instead of singular?
A: Plural maps naturally to collections. POST /users creates a user and returns /users/123. With /user, it is unclear whether you are creating a new user or referring to the current one.

Q: What HTTP status codes go with each CRUD operation?
A: GET returns 200 (found) or 404 (not found). POST returns 201 Created with Location. PUT and PATCH return 200 (with body) or 204 (no body). DELETE returns 204 No Content.

Q: What is the difference between PUT and PATCH?
A: PUT replaces the entire resource, PATCH applies partial changes. GitHub uses PATCH /repos/:id to update just the description without overwriting other fields.

Q: How does API versioning work?
A: Two main options: URL prefix (/api/v1/users) and header-based (Accept: application/vnd.myapi.v2+json). URL versioning is easier to route and debug. Header versioning is cleaner but requires client-side configuration.

Q: How does caching work with REST?
A: The server sets an ETag (a hash of the response) on GET responses. The client stores it and sends If-None-Match: <etag> on the next request. If nothing changed, the server returns 304 Not Modified with no body. CDNs apply the same logic automatically for public endpoints.

Q: (Senior) Design a REST API for a blog with posts, comments, search, and auth. What endpoints, headers, and status codes would you use?
A: Start with GET /posts?page=1&search=foo returning { data: [], _links: { next: { href: '...' } } }. Auth via Authorization: Bearer <jwt>. Create with POST /posts returning 201 plus Location. Comments at GET /posts/:id/comments. Rate limiting via X-RateLimit-Limit and X-RateLimit-Remaining headers. Include HATEOAS links in each response so clients can navigate without hardcoding URLs.

Examples

Basic CRUD for a user resource

javascript
const express = require('express'); const app = express(); app.use(express.json()); let users = [ { id: 1, name: 'Alice', email: 'alice@example.com' } ]; // GET /users - list all app.get('/users', (req, res) => res.json(users)); // GET /users/:id - get one app.get('/users/:id', (req, res) => { const user = users.find(u => u.id === +req.params.id); user ? res.json(user) : res.status(404).json({ error: 'User not found' }); }); // POST /users - create app.post('/users', (req, res) => { const user = { id: users.length + 1, ...req.body }; users.push(user); res.status(201).set('Location', `/users/${user.id}`).json(user); }); // PATCH /users/:id - partial update app.patch('/users/:id', (req, res) => { const user = users.find(u => u.id === +req.params.id); if (!user) return res.status(404).json({ error: 'Not found' }); Object.assign(user, req.body); // merge changes, not replace res.json(user); }); // DELETE /users/:id app.delete('/users/:id', (req, res) => { users = users.filter(u => u.id !== +req.params.id); res.status(204).send(); }); app.listen(3000);

Each method maps to one action. The URL (/users/:id) stays the same, only the HTTP method changes. PATCH uses Object.assign to merge changes rather than overwrite. DELETE returns 204 with no body, because there is nothing left to describe.

Pagination and filtering

javascript
app.get('/users', (req, res) => { const { page = 1, limit = 10, name } = req.query; let filtered = users; if (name) filtered = filtered.filter(u => u.name.includes(name)); const start = (page - 1) * limit; const data = filtered.slice(start, +start + +limit); res.json({ data, pagination: { page: +page, limit: +limit, total: filtered.length, pages: Math.ceil(filtered.length / limit) } }); }); // curl "localhost:3000/users?page=1&limit=2&name=Alice" // -> { data: [{id:1,name:'Alice',...}], pagination: {page:1,limit:2,total:1,pages:1} }

Never return an unbounded list. Any endpoint that hits a database table without LIMIT will fail under load. This pattern matches what Stripe and GitHub use: a data array, pagination metadata, and optional filtering via query params. I have seen this exact bug, a GET /orders that worked fine in development and timed out on the first Monday after launch.

javascript
// PATCH with JSON Patch (RFC 6902) - array of operations app.patch('/users/:id', (req, res) => { const user = users.find(u => u.id === +req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); // req.body: [{ op: 'replace', path: '/name', value: 'Alicia' }] req.body.forEach(op => { if (op.op === 'replace') user[op.path.slice(1)] = op.value; }); res.json({ ...user, _links: { self: { href: `/users/${user.id}` }, posts: { href: `/users/${user.id}/posts` } } }); }); // curl -X PATCH localhost:3000/users/1 \ // -H "Content-Type: application/json" \ // -d '[{"op":"replace","path":"/name","value":"Alicia"}]' // -> { id: 1, name: 'Alicia', _links: { self: {href:'/users/1'}, posts: {href:'/users/1/posts'} } }

JSON Patch (RFC 6902) sends an array of operations instead of a partial object. The intent is explicit: you are not saying "here is what the object looks like now," you are saying "apply this specific change." The _links object follows GitHub's approach, where clients can discover related resources without hardcoding URLs.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?