REST API Design
Representational State Transfer, or REST, is the dominant architectural style for designing networked APIs. It models interactions as stateless transfers of resource representations between clients and servers. When designed well, REST APIs are predictable, cacheable, and easy to evolve.
REST is not a protocol or a standard. It is a set of constraints that, when applied to HTTP, produce systems that scale and compose. The core ideas are resources identified by URLs, a uniform interface of HTTP methods, stateless request processing, and representations negotiated through media types.
This guide covers the practical decisions you will face when building a REST API: naming resources, choosing HTTP methods, returning correct status codes, versioning safely, linking related resources with HATEOAS, documenting with OpenAPI, and avoiding the anti-patterns that make APIs hard to consume.
In REST, URLs identify resources, not actions. A resource is any noun that is meaningful to your domain: users, orders, invoices, comments. Use plural nouns for collections and path segments for hierarchy. Avoid verbs in URLs because HTTP methods already express actions.
Consistency matters more than perfection. Pick a convention and apply it everywhere. Use kebab-case for multi-word names. Keep URLs shallow when possible. Deeply nested resources become hard to read and can force consumers to know relationships they should not care about.
| Avoid | Prefer | Reason |
|---|---|---|
| /getUsers | /users | HTTP method implies the action |
| /users/123/posts/create | POST /users/123/posts | POST on collection creates a member |
| /user/123 | /users/123 | Plural nouns for collections |
| /orders?status=archive | GET /orders?status=archived | Use adjectives for filter values |
| 1 | // Express router with RESTful resource routes |
| 2 | import { Router } from "express"; |
| 3 | |
| 4 | const usersRouter = Router(); |
| 5 | |
| 6 | usersRouter.get("/", listUsers); // collection read |
| 7 | usersRouter.post("/", createUser); // collection create |
| 8 | usersRouter.get("/:id", getUser); // member read |
| 9 | usersRouter.patch("/:id", updateUser); // member partial update |
| 10 | usersRouter.put("/:id", replaceUser); // member full replacement |
| 11 | usersRouter.delete("/:id", deleteUser); // member delete |
| 12 | |
| 13 | usersRouter.get("/:id/orders", listUserOrders); // sub-resource collection |
| 14 | usersRouter.post("/:id/orders", createUserOrder); |
| 15 | |
| 16 | export default usersRouter; |
info
HTTP provides a small vocabulary of methods, each with clear semantics. Using them consistently makes your API self-documenting and lets intermediaries such as caches and proxies behave correctly.
| Method | Action | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes |
| POST | Create or trigger action | No | No |
| PUT | Full replacement | Yes | No |
| PATCH | Partial update | Usually | No |
| DELETE | Remove a resource | Yes | No |
| HEAD | Retrieve headers only | Yes | Yes |
| OPTIONS | Describe capabilities | Yes | Yes |
Idempotency is one of the most important properties for reliable APIs. A request is idempotent if repeating it produces the same server state as making it once. GET, PUT, DELETE, and OPTIONS are idempotent. POST is not because creating a resource twice usually creates two resources.
warning
HTTP status codes communicate the outcome of a request. Using the right code lets clients decide whether to retry, redirect, or surface an error. Do not invent custom status codes for conditions that existing codes already cover.
| Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Success | Successful GET, PUT, PATCH, DELETE |
| 201 Created | Resource created | Successful POST that creates a resource |
| 204 No Content | Success with empty body | DELETE or actions with no response |
| 400 Bad Request | Client error | Validation failures, malformed JSON |
| 401 Unauthorized | Authentication required | Missing or invalid credentials |
| 403 Forbidden | Not allowed | Authenticated but lacks permission |
| 404 Not Found | Resource missing | Unknown URL or deleted resource |
| 409 Conflict | State conflict | Duplicate email, stale optimistic lock |
| 422 Unprocessable Entity | Semantic error | Valid syntax but business rule violation |
| 429 Too Many Requests | Rate limited | Client exceeded rate limit |
| 500 Internal Server Error | Server failure | Unexpected exception |
| 503 Service Unavailable | Temporarily down | Maintenance, overload, upstream outage |
best practice
Consistency in request and response bodies reduces friction for API consumers. Use JSON for most APIs unless binary performance is critical. Wrap collection responses in an object so you can add pagination, metadata, and links without breaking clients.
| 1 | { |
| 2 | "data": [ |
| 3 | { |
| 4 | "id": "usr_123", |
| 5 | "email": "alice@example.com", |
| 6 | "name": "Alice Chen", |
| 7 | "createdAt": "2026-01-15T08:23:00Z", |
| 8 | "links": { |
| 9 | "self": "/users/usr_123", |
| 10 | "orders": "/users/usr_123/orders" |
| 11 | } |
| 12 | } |
| 13 | ], |
| 14 | "meta": { |
| 15 | "total": 42, |
| 16 | "page": 1, |
| 17 | "perPage": 20 |
| 18 | }, |
| 19 | "links": { |
| 20 | "self": "/users?page=1", |
| 21 | "next": "/users?page=2", |
| 22 | "last": "/users?page=3" |
| 23 | } |
| 24 | } |
Accept and return dates in ISO 8601 UTC format. Use snake_case or camelCase consistently for JSON keys. If your API is public, pick snake_case because it is the most common convention across languages and libraries.
info
APIs evolve. New fields are added, behavior changes, and old endpoints are deprecated. Versioning lets you introduce breaking changes without forcing every client to update simultaneously. The three most common strategies are URL path versioning, header versioning, and content negotiation.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users | Explicit, easy to cache | Clutters URLs |
| Header | X-API-Version: 2025-01-01 | Clean URLs | Harder to test in browser |
| Content type | Accept: application/vnd.api+json;v=2 | Strict HTTP semantics | Verbose, poor tooling support |
URL path versioning is the safest default for most teams. It is visible, works with every HTTP client, and is easy to route in load balancers and CDNs. Reserve header or content-type versioning for APIs where URL cleanliness is critical.
warning
Hypermedia as the Engine of Application State, or HATEOAS, is the REST constraint that says clients should navigate the API by following links provided in responses, not by hard-coding URLs. This reduces coupling between clients and servers because the server controls available actions.
| 1 | { |
| 2 | "data": { |
| 3 | "id": "ord_456", |
| 4 | "status": "pending", |
| 5 | "total": 199.99, |
| 6 | "links": { |
| 7 | "self": { "href": "/v1/orders/ord_456" }, |
| 8 | "customer": { "href": "/v1/users/usr_123" }, |
| 9 | "items": { "href": "/v1/orders/ord_456/items" } |
| 10 | }, |
| 11 | "actions": [ |
| 12 | { |
| 13 | "rel": "cancel", |
| 14 | "method": "POST", |
| 15 | "href": "/v1/orders/ord_456/cancel", |
| 16 | "condition": "status === 'pending'" |
| 17 | }, |
| 18 | { |
| 19 | "rel": "pay", |
| 20 | "method": "POST", |
| 21 | "href": "/v1/orders/ord_456/payments", |
| 22 | "condition": "status === 'pending'" |
| 23 | } |
| 24 | ] |
| 25 | } |
| 26 | } |
Full HATEOAS is rare in modern APIs because it adds payload size and client complexity. A pragmatic compromise is to include a small set of stable links, such as self, next, prev, and related. This gives clients useful navigation without requiring a hypermedia client library.
note
OpenAPI is the industry standard for describing REST APIs. An accurate OpenAPI document becomes the source of truth for documentation, client SDK generation, mock servers, and contract tests. Generate it from code annotations or TypeScript types to keep it in sync with the implementation.
| 1 | openapi: 3.0.3 |
| 2 | info: |
| 3 | title: ForgeLearn Orders API |
| 4 | version: 1.0.0 |
| 5 | paths: |
| 6 | /users: |
| 7 | get: |
| 8 | summary: List users |
| 9 | parameters: |
| 10 | - name: page |
| 11 | in: query |
| 12 | schema: |
| 13 | type: integer |
| 14 | default: 1 |
| 15 | responses: |
| 16 | "200": |
| 17 | description: A paginated list of users |
| 18 | content: |
| 19 | application/json: |
| 20 | schema: |
| 21 | type: object |
| 22 | properties: |
| 23 | data: |
| 24 | type: array |
| 25 | items: |
| 26 | $ref: "#/components/schemas/User" |
| 27 | meta: |
| 28 | $ref: "#/components/schemas/PaginationMeta" |
| 29 | components: |
| 30 | schemas: |
| 31 | User: |
| 32 | type: object |
| 33 | properties: |
| 34 | id: |
| 35 | type: string |
| 36 | email: |
| 37 | type: string |
| 38 | format: email |
| 39 | required: [id, email] |
best practice
Even experienced teams fall into recurring traps. Recognizing them early saves refactoring later.
| Anti-Pattern | Problem | Solution |
|---|---|---|
| 200 with error body | Clients cannot detect failures | Use proper 4xx/5xx codes |
| POST for everything | Loses caching and semantics | Use GET, PUT, PATCH, DELETE |
| Returning database rows | Leaks schema and internals | Map to DTOs / view models |
| No pagination | Unbounded list endpoints | Default page size, max limit |
| Inconsistent naming | Cognitive load for consumers | Adopt and enforce a style guide |
| Exposing stack traces | Security risk | Return generic messages, log details |
danger