Most REST APIs aren't badly built — they're badly designed. The code works, but the endpoints are inconsistent, the error responses are a guessing game, and every new consumer files the same questions. A good REST API is predictable: once a developer learns one endpoint, they can guess how the rest behave. This guide is a practical, vendor-neutral walk through the decisions that get you there.
The short version: model resources as nouns, use HTTP verbs and status codes for their actual meaning, keep your error shape identical everywhere, paginate every collection, version before you break anything, and treat authentication as a first-class part of the contract.
What "RESTful" actually buys you
REST is a set of constraints, not a rulebook to recite. The constraint that pays off in daily work is the uniform interface: clients and servers agree that resources are addressed by URLs, manipulated with a small set of HTTP methods, and described by representations (usually JSON). The practical payoff is predictability. You don't need a custom verb for every action when GET, POST, PATCH, and DELETE already carry meaning that every HTTP client, proxy, and cache understands.
You don't have to chase "level 3" hypermedia purity to get value. Aim for consistency first; add links and discoverability only where they earn their keep.
Model resources, not actions
The core design move is naming things as resources (nouns) and letting HTTP methods supply the verb.
GET /articles # list articles
POST /articles # create an article
GET /articles/42 # fetch one article
PATCH /articles/42 # partially update it
DELETE /articles/42 # remove it
A few rules that keep this clean:
- Plural collection names (
/articles, not/article). It reads consistently for both the collection and its members. - Nest only for ownership, and stop at one level:
/articles/42/commentsis fine;/articles/42/comments/9/author/postsis a maintenance trap. Once you've identified a resource, link to it by its own top-level path. - Avoid verbs in paths.
POST /articles/42/publishis a common exception for true state transitions, but reach for it only when a state machine genuinely can't be expressed as a field update.
Why nouns over actions? Because the set of nouns in your domain is small and stable, while the set of actions is large and grows forever. Designing around resources keeps the surface area bounded.
Use HTTP methods and status codes honestly
The biggest source of API confusion is verbs and status codes that lie. Two properties are worth protecting:
- Safe methods (
GET,HEAD) never change state. Clients, browsers, and crawlers assume this — aGETthat mutates data will eventually corrupt something. - Idempotent methods (
GET,PUT,DELETE) produce the same result when retried. This matters because networks fail mid-request, and clients retry.POSTis the odd one out — it's neither safe nor idempotent, which is why "create" needs care (see idempotency below).
For status codes, use the categories for what they mean rather than returning 200 with an error field:
| Code | Meaning | Use it when |
|---|---|---|
| 200 | OK | Successful GET/PATCH with a body |
| 201 | Created | POST created a resource (return it + Location) |
| 204 | No Content | Success with nothing to return (e.g. DELETE) |
| 400 | Bad Request | Malformed or invalid input |
| 401 | Unauthorized | Missing/invalid credentials |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | State conflict (duplicate, version mismatch) |
| 422 | Unprocessable | Syntactically valid but semantically wrong |
| 429 | Too Many Requests | Rate limited |
| 500 | Server Error | An unhandled failure on your side |
The line that trips teams up most is 401 vs 403: 401 means "I don't know who you are," 403 means "I know who you are, and you can't do this." Returning the wrong one sends clients down the wrong recovery path.
Make errors machine-readable and consistent
A consumer should be able to write one error handler, not one per endpoint. Pick a single error shape and return it everywhere — including from your middleware and framework defaults. A widely supported option is RFC 9457 (application/problem+json):
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "The 'email' field must be a valid email address.",
"errors": [
{ "field": "email", "message": "must be a valid email address" }
]
}
Whatever shape you choose, make it stable: a top-level human title, a machine-readable code or type, and a detail for debugging. Never leak stack traces or SQL in production responses — that's both a usability problem and a security one.
Paginate, filter, and sort every collection
Any endpoint that returns a list will eventually return too many. Decide on pagination before that happens, because changing it later breaks clients.
- Offset pagination (
?page=2&per_page=50) is simple and lets clients jump to a page, but it gets slow and inconsistent on large, frequently changing tables. - Cursor pagination (
?limit=50&cursor=eyJpZCI6NDJ9) is stable under inserts and scales well, at the cost of no "jump to page 9." Prefer it for large or fast-moving datasets.
Either way, return the metadata clients need to continue:
{
"data": [ /* ... */ ],
"page": { "next_cursor": "eyJpZCI6OTJ9", "has_more": true }
}
Expose filtering and sorting as query parameters (?status=published&sort=-created_at), and document exactly which fields are filterable — an unbounded filter surface is hard to index and easy to abuse.
Version before you break things
You will need to change the contract eventually. The goal is to never break existing clients silently. The common approaches, with their trade-offs:
- URL versioning (
/v1/articles) — most visible and easiest to route, cache, and reason about. The downside is it's coarse: the whole API moves together. This is the safest default for most teams. - Header versioning (
Accept: application/vnd.example.v2+json) — keeps URLs clean and allows finer-grained negotiation, but it's harder to test by hand and easier for clients to get wrong.
Whichever you pick, treat additive changes (new optional fields, new endpoints) as non-breaking and ship them freely; reserve a version bump for removals and changed semantics. Document a deprecation window and announce it with a Deprecation or Sunset header.
Treat authentication as part of the contract
Auth isn't a bolt-on; it shapes every request. Two patterns cover most APIs:
- API keys for server-to-server traffic — simple, but treat each key as a secret, scope it to least privilege, and support rotation.
- Bearer tokens (OAuth 2.0 / JWT) for user-delegated access — standard, widely tooled, and expirable.
Always send credentials in headers over TLS, never in the query string (URLs end up in logs and browser history):
GET /v1/articles HTTP/1.1
Host: api.example.com
Authorization: Bearer YOUR_API_KEY
Pair auth with rate limiting and surface it honestly: return 429 with a Retry-After header so well-behaved clients can back off instead of hammering you.
Bonus: idempotency for safe retries
Because POST isn't idempotent, a retried "create payment" can charge a customer twice. The standard fix is an idempotency key: the client sends a unique Idempotency-Key header, and the server records the first result and replays it for any retry with the same key. It's a small amount of work that removes a whole class of duplicate-write bugs.
A REST design checklist
- Resources as nouns, plural collections, shallow nesting.
- Honest verbs and status codes — safe and idempotent where promised.
- One error shape everywhere, machine-readable, no leaked internals.
- Pagination on every collection, with documented filters and sorts.
- Versioned contract — additive changes free, breaking changes behind a version.
- Auth in headers over TLS, scoped, rotatable, rate-limited.
FAQ
When should I use PUT vs PATCH?
Use PUT to replace a resource with a full representation (idempotent), and PATCH to apply a partial update. In practice most update endpoints are PATCH, because clients rarely have — or want to send — the entire object.
Is REST still the right choice over GraphQL or gRPC?
Often, yes. REST wins on simplicity, caching, and tooling ubiquity, which is exactly what most public and internal HTTP APIs need. Reach for GraphQL when clients need flexible, client-shaped queries over a graph, and gRPC for high-throughput internal service-to-service calls. Choose based on the consumer, not fashion.
Should I version my API from day one?
Yes — at least put a /v1 in the path. It costs nothing now and saves a painful migration later, because adding versioning to an unversioned, live API is far harder than starting with it.
How do I avoid breaking clients when I add fields?
Make additions optional and never repurpose an existing field's meaning. Clients should ignore unknown fields, and you should never remove or rename a field without a version bump and a deprecation window.
Next step
This week, pick one endpoint in your API that consumers complain about. Model it as a clean resource, fix its status codes and error shape, paginate it if it returns a list, and ship the change behind a version. One predictable endpoint at a time is how an API becomes a pleasure to use.