tool / 78
REST Patterns
What to do, what to avoid, and why. The patterns most APIs get wrong, with before/after examples.
All local
23/23
URLs3
Resource naming
avoid
GET /getUsers POST /createUser POST /deleteUser?id=42 GET /user_list?status=active
prefer
GET /users POST /users DELETE /users/42 GET /users?status=active
Use plural nouns and let the HTTP method be the verb. Lowercase, hyphenated. Keep it boring and predictable.
Hierarchy depth
avoid
GET /users/42/orders/13/items/7/options/red
prefer
GET /users/42/orders/13 GET /order-items/7?option=red
Stop at one level of nesting. Beyond that, switch to top-level resources or query params. Long paths break callers and obscure intent.
Singletons vs collections
avoid
GET /user/42 // singular for an instance GET /users/list // redundant collection suffix
prefer
GET /users // collection GET /users/42 // instance GET /users/me // current user is a known singleton
Plural for collections, singular nicknames only for known singletons (`/me`, `/current`).
Methods3
Use the right method
avoid
POST /users/42/delete GET /users/42/update?email=new@x.com
prefer
DELETE /users/42
PATCH /users/42 { "email": "new@x.com" }GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes. Don't tunnel everything through POST or GET.
PUT vs PATCH
avoid
PUT /users/42 { "email": "new@x.com" }
// only updates email — but PUT means REPLACE so the rest of the resource is now undefinedprefer
PATCH /users/42 { "email": "new@x.com" }
// or
PUT /users/42 { ...wholeUserObject, "email": "new@x.com" }PUT replaces the entire resource. PATCH updates a subset. Mixing them up creates data loss bugs.
Idempotent POST via Idempotency-Key
avoid
POST /charges { ... }
// network blip → client retries → user charged twiceprefer
POST /charges Idempotency-Key: 4f9e1c-uuid // server records the key. Replay with the same key returns the original result.
POST isn't idempotent by default. For payment-like operations, add an Idempotency-Key header (Stripe popularized this).
Status4
Use the right status code
avoid
200 OK
{ "error": "user not found" }prefer
404 Not Found
{ "error": "user not found" }Don't return 200 with an error in the body. HTTP status codes exist; use them. Caches, monitors and clients all rely on them.
201 vs 200 for create
avoid
POST /users → 200 OK
{ "id": 42 }prefer
POST /users → 201 Created
Location: /users/42
{ "id": 42, "email": "..." }Use 201 with a Location header on successful creates. Tells the client exactly where to find the new resource.
401 vs 403
avoid
401 Unauthorized for "you're logged in but not allowed"
prefer
401 — not authenticated (or expired) 403 — authenticated but no permission
401 says 'who are you'. 403 says 'I know you, you can't have it'. Don't conflate them.
422 for validation
avoid
400 Bad Request
{ "error": "email is invalid" }prefer
422 Unprocessable Entity
{
"errors": [
{ "field": "email", "message": "must be a valid email" }
]
}400 means the request itself was malformed. Use 422 for well-formed requests that fail business validation. Return field-level errors.
Payloads3
Wrap collections
avoid
// /users
[
{ "id": 1, "name": "Ada" },
{ "id": 2, "name": "Grace" }
]prefer
{
"data": [
{ "id": 1, "name": "Ada" },
{ "id": 2, "name": "Grace" }
],
"meta": { "page": 1, "total": 247 }
}Bare arrays leave no room to add metadata later (pagination, totals, links). Wrap from day one.
Consistent error shape
avoid
// one endpoint
{ "error": "bad" }
// another
{ "msg": "failed", "code": 1234 }
// a third
"Internal server error"prefer
// every endpoint
{
"error": {
"code": "user_not_found",
"message": "No user with id 42",
"details": { "id": 42 }
}
}Pick one error shape and use it everywhere. RFC 7807 (Problem Details) is a sensible standard.
Don't leak DB models
avoid
// returning the raw ORM entity
{ "id": 42, "email": "...", "passwordHash": "$2b$12$...", "internalNote": "vip" }prefer
// explicit DTO
{ "id": 42, "email": "...", "displayName": "Ada" }Returning the database row by accident leaks fields the API never meant to expose. Use a DTO.
Pagination2
Cursor over offset
avoid
GET /users?page=4500&size=20 // slow on big tables, breaks when items shift
prefer
GET /users?after=cursor_xyz&limit=20
{ "data": [...], "next_cursor": "abc..." }Offset pagination is slow at scale and inconsistent under writes. Cursor pagination is consistent, fast and well-suited to infinite scroll.
Always set a default and max limit
avoid
GET /users?limit=99999999
prefer
// server caps limit at 100 default 20, max 100
If you don't enforce a max, someone will request a million records and OOM your service.
Versioning2
Version in the URL
avoid
GET /users // v1 GET /users-v2 // v2 (string in path is hard to evolve)
prefer
GET /v1/users GET /v2/users
Major version in the URL path is the simplest model — easy to test, route and document. Header-based versioning is technically purer but more painful in practice.
Don't break v1
avoid
// silently changing v1 response shape
prefer
// freeze v1, deprecate it, ship v2 alongside
API versions are contracts. Don't change them. Add a new version, run both, deprecate the old one with a sunset header.
Patterns6
Filtering and sorting
avoid
GET /users/active GET /users/sorted-by-created
prefer
GET /users?status=active&sort=-created_at
Use query params for filtering and sorting, not new URL paths. Minus prefix for descending. Keep it consistent.
Sparse fieldsets
avoid
// always returning every field
prefer
GET /users?fields=id,name,email
Let clients ask for only the fields they need. Saves bandwidth and lets you add new fields without breaking smaller clients.
Bulk operations
avoid
// firing 1000 PATCH requests PATCH /users/1 PATCH /users/2 ...
prefer
PATCH /users
[
{ "id": 1, "active": true },
{ "id": 2, "active": false }
]Add a bulk variant for any operation users will do at scale. Saves round trips and lets you batch atomically.
Async / long-running
avoid
POST /reports/generate // blocks for 90 seconds
prefer
POST /reports → 202 Accepted, Location: /reports/jobs/abc
GET /reports/jobs/abc → { "status": "running" }
GET /reports/jobs/abc → { "status": "done", "url": "..." }For anything slower than ~5 seconds, return 202 with a job URL the client can poll. Don't hold the connection open.
Rate limiting headers
avoid
429 Too Many Requests
{ "error": "rate limited" }
// no infoprefer
429 Too Many Requests Retry-After: 30 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 1700000000
When you rate-limit, tell the client when they can try again. Retry-After is mandatory; the X-RateLimit-* headers are convention.
HATEOAS sparingly
avoid
// hand-rolling URL strings on the client
const url = `/users/${id}/orders`;prefer
// API includes related links
{
"id": 42,
"_links": {
"self": "/users/42",
"orders": "/users/42/orders"
}
}HATEOAS is cool in theory, painful in practice. Use it lightly: include links to related resources so clients don't hand-build URLs, but don't go full HAL/JSON-API unless the team has bought in.