Understanding REST API Design Best Practices
A practical guide to designing REST APIs that developers love — covering resource modeling, HTTP semantics, status codes, versioning, and error handling.
A REST API is a contract. Every decision you make when designing it — URL structure, status codes, error formats, versioning — becomes a constraint your consumers must work around. Get it right upfront, and integration is smooth. Get it wrong, and you’ll be maintaining backwards-compatibility hacks for years.
Here’s what separates a well-designed REST API from a frustrating one.
Think in Resources, Not Actions
REST is resource-oriented. URLs identify things, HTTP methods express what you’re doing to them.
# Wrong — action-based URLs
POST /createUser
GET /getUserById?id=42
POST /deleteUser
POST /updateUserEmail
# Right — resource-based URLs
POST /users # create a user
GET /users/42 # get a specific user
DELETE /users/42 # delete a user
PATCH /users/42 # partially update a user
The HTTP method carries the semantic meaning. Your URL should only identify what resource is being acted on.
Nested Resources
Use nesting for resources that belong to a parent:
GET /users/42/orders # all orders for user 42
GET /users/42/orders/7 # specific order
POST /users/42/orders # create order for user 42
Limit nesting to one or two levels deep. Beyond that, use query parameters or flat resource paths.
HTTP Methods: Use Them Correctly
Each HTTP method carries a specific meaning that clients and infrastructure depend on.
| Method | Semantics | Safe | Idempotent |
|---|---|---|---|
| GET | Read | Yes | Yes |
| POST | Create | No | No |
| PUT | Full replace | No | Yes |
| PATCH | Partial update | No | No |
| DELETE | Delete | No | Yes |
Safe means the request doesn’t modify state — caches and intermediaries can call it freely.
Idempotent means calling it multiple times produces the same result — important for retries on network failure.
# PUT should fully replace the resource
PUT /users/42
{ "name": "Alice Smith", "email": "alice@example.com", "role": "admin" }
# PATCH updates only the fields provided
PATCH /users/42
{ "email": "newalice@example.com" }
Status Codes: Say What You Mean
HTTP status codes are a semantic protocol. Using them correctly means clients can handle responses without parsing error messages.
Success (2xx)
200 OK — general success (GET, PUT, PATCH)
201 Created — resource was created (POST), include Location header
204 No Content — success with no body (DELETE)
Client Errors (4xx)
400 Bad Request — invalid input, validation failure
401 Unauthorized — not authenticated (missing/invalid token)
403 Forbidden — authenticated but not permitted
404 Not Found — resource doesn't exist
409 Conflict — state conflict (duplicate key, version mismatch)
422 Unprocessable — valid format but semantic errors (can't process)
429 Too Many Requests — rate limited
The most common mistake: returning 200 OK with an error in the body. That makes every client parse the response body just to check if the request succeeded.
Consistent Error Responses
Define a standard error format and use it everywhere:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"field": "email",
"requestId": "req_abc123"
}
}
Key principles:
- Always include a machine-readable
code(not just the HTTP status) - Include a human-readable
messagefor debugging - Include a
requestIdso users can reference it in support tickets - For validation errors, include which
fieldfailed
// Multiple validation errors
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "req_abc123",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "Must be 18 or older" }
]
}
}
Versioning
APIs change over time. Versioning lets you make breaking changes without breaking existing integrations.
URL Versioning (Most Common)
GET /v1/users/42
GET /v2/users/42
Explicit, easy to understand, and works everywhere. The downside: technically violates REST (the version isn’t a resource), but this is the pragmatic choice for most teams.
Header Versioning
GET /users/42
Accept: application/vnd.myapi.v2+json
Cleaner URLs but harder to test in a browser. Use this if you have strong REST purists on the team or if your API is a long-lived platform product.
Rule: Once you publish a versioned API, never make breaking changes to it. Add fields (non-breaking), don’t remove them. Introduce new behavior in /v2, not by silently changing /v1.
Pagination
Never return unbounded lists. Always paginate collections.
Cursor-Based (Preferred)
GET /posts?cursor=abc123&limit=20
Response:
{
"data": [...],
"pagination": {
"nextCursor": "def456",
"hasMore": true
}
}
Cursor pagination is stable — inserting new records doesn’t cause items to skip or duplicate across pages.
Offset-Based
GET /posts?page=3&per_page=20
Response:
{
"data": [...],
"pagination": {
"page": 3,
"perPage": 20,
"total": 147,
"totalPages": 8
}
}
Offset pagination is simpler to implement and supports random page access. It breaks under concurrent inserts, but that’s often acceptable for non-real-time data.
Filtering, Sorting, and Searching
Use query parameters for these operations:
GET /users?status=active # filter
GET /posts?sort=created_at&order=desc # sort
GET /users?q=alice # search
GET /orders?created_after=2026-01-01 # date range
GET /products?category=tools&min_price=10 # combine filters
Keep query parameter names consistent across all your endpoints. If it’s sort on one endpoint, it shouldn’t be order_by on another.
Security Defaults
A few non-negotiable security practices:
Always use HTTPS. Redirect HTTP to HTTPS; don’t serve APIs over plain HTTP.
Return minimal data. Don’t include internal IDs, passwords, server paths, or internal state in responses. Think about what a consumer needs, not what’s convenient to serialize.
Set CORS correctly. Don’t use Access-Control-Allow-Origin: * for APIs that handle authenticated data.
Rate limit everything. Even internal APIs. Return 429 Too Many Requests with a Retry-After header when limits are hit:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711000000
Documentation as Contract
The best APIs treat documentation as part of the product, not an afterthought. Use OpenAPI/Swagger to define your API schema:
paths:
/users/{id}:
get:
summary: Get a user by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: User found
'404':
description: User not found
An OpenAPI spec generates documentation, client SDKs, and mock servers. It’s the single source of truth for your API contract.
Summary
Good REST API design comes down to consistency and respecting the HTTP protocol:
- URLs identify resources (nouns), methods express actions (verbs)
- Use HTTP methods and status codes as intended
- Define a standard error format and use it everywhere
- Version from day one — breaking changes need new versions
- Always paginate collections
- Document with OpenAPI
The payoff is an API that developers can integrate confidently, that’s easy to maintain, and that grows without accumulating technical debt.
Free Newsletter
Level Up Your Dev Workflow
Get new tools, guides, and productivity tips delivered to your inbox.
Plus: grab the free Developer Productivity Checklist when you subscribe.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.