Why REST API Design Matters
An API is the front door of a service. When visitors (clients) enter a building (server), clear signage makes it easy to find what they need. A well-designed API should be intuitive enough to use without reading the documentation.
REST (Representational State Transfer) is an architectural style leveraging HTTP. Its core principles are resource-centric design, HTTP method usage, and statelessness.
URI Naming Rules
Basic Principles
# Bad design — uses verbs, inconsistent
GET /getUsers
POST /createNewUser
GET /user/delete/123
POST /updateUserProfile
# Good design — nouns, plural, hierarchical
GET /users # List users
POST /users # Create user
GET /users/123 # Get specific user
PUT /users/123 # Full update of user
PATCH /users/123 # Partial update of user
DELETE /users/123 # Delete user
Naming Checklist
| Rule | Good example | Bad example |
|---|
| Use plural nouns | /users, /orders | /user, /order |
| Use lowercase | /user-profiles | /UserProfiles |
| Use hyphens | /blog-posts | /blog_posts, /blogPosts |
| No verbs | POST /users | /createUser |
| No file extensions | /users/123 | /users/123.json |
| Express relationships | /users/123/orders | /getUserOrders?id=123 |
Relational Resources
# User's order list
GET /users/123/orders
# User's specific order
GET /users/123/orders/456
# Order's payment info
GET /users/123/orders/456/payment
# Avoid deep nesting (3+ levels)
# Bad: /users/123/orders/456/items/789/reviews
# Good: /order-items/789/reviews
HTTP Methods and Status Codes
Method Semantics
| Method | Purpose | Idempotent | Safe | Request body |
|---|
| GET | Read | Yes | Yes | None |
| POST | Create | No | No | Yes |
| PUT | Full replace | Yes | No | Yes |
| PATCH | Partial update | No | No | Yes |
| DELETE | Delete | Yes | No | None/Optional |
Status Code Guide
# Success responses
200 OK # General success (GET, PUT, PATCH, DELETE)
201 Created # Resource created successfully (POST)
204 No Content # Success with no response body (DELETE)
# Client errors
400 Bad Request # Request data validation failed
401 Unauthorized # Authentication failed (no/expired token)
403 Forbidden # Authorization failed (no permission)
404 Not Found # Resource not found
409 Conflict # Conflict (duplicate data, etc.)
422 Unprocessable # Valid syntax but semantic error
429 Too Many Requests # Rate limit exceeded
# Server errors
500 Internal Error # Internal server error
502 Bad Gateway # Upstream server error
503 Service Unavailable # Service temporarily unavailable
Error Response Patterns
A consistent error response format greatly improves client developer productivity.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input data is invalid.",
"details": [
{
"field": "email",
"message": "Not a valid email format.",
"value": "invalid-email"
},
{
"field": "age",
"message": "Age must be 0 or greater.",
"value": -5
}
],
"timestamp": "2026-02-19T14:30:00Z",
"request_id": "req_abc123"
}
}
Express.js Error Handling Implementation
// Error response helper class
class ApiError extends Error {
constructor(statusCode, code, message, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code; // Error code (machine-readable)
this.details = details; // Details array
}
}
// Create validation error
function validationError(details) {
return new ApiError(
400,
"VALIDATION_ERROR",
"Input data is invalid.",
details
);
}
// Global error handler
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const response = {
error: {
code: err.code || "INTERNAL_ERROR",
message: err.message || "An internal server error occurred.",
details: err.details || [],
timestamp: new Date().toISOString(),
request_id: req.id // Request tracking ID
}
};
// Hide details for 500 errors (security)
if (statusCode === 500) {
console.error("Server error:", err);
response.error.message = "An internal server error occurred.";
response.error.details = [];
}
res.status(statusCode).json(response);
});
# Cursor-based (recommended — suitable for large datasets)
GET /users?cursor=eyJpZCI6MTAwfQ&limit=20
# Response
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true,
"limit": 20
}
}
# Offset-based (simple but degrades at scale)
GET /users?page=3&per_page=20
# Response
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 20,
"total": 150,
"total_pages": 8
}
}
| Approach | Pros | Cons |
|---|
| Cursor-based | Stable at scale, real-time data | Unknown total pages, no random access |
| Offset-based | Simple implementation, random page access | Duplicates/misses on data changes, slow |
Versioning
# Method 1: URL path (most intuitive, recommended)
GET /v1/users
GET /v2/users
# Method 2: Query parameter
GET /users?version=2
# Method 3: Header
GET /users
Accept: application/vnd.myapi.v2+json
| Method | Pros | Cons |
|---|
| URL path | Clear, easy to cache | URL changes |
| Query parameter | Preserves URL structure | Default value confusion |
| Header | Clean URLs | Difficult to test, low discoverability |
Filtering, Sorting, and Search
# Filtering — use query parameters
GET /users?status=active&role=admin
# Sorting — sort parameter
GET /users?sort=-created_at,name
# - prefix means descending, default is ascending
# Search — q or search parameter
GET /users?q=alice
# Field selection — fields parameter (optimize response size)
GET /users?fields=id,name,email
# Combined usage
GET /users?status=active&sort=-created_at&fields=id,name&limit=10
Practical Tips
- Consistency is paramount: Unify naming, response format, and error structure across the entire project. An inconsistent API is difficult to use no matter how good the documentation is.
- Choose pragmatism over HATEOAS: A developer-friendly API is better than a theoretically perfect REST API.
- Apply rate limiting from the start: Include
429 responses and Retry-After headers. Adding them later makes it hard for existing clients to adapt.
- Include a request ID in every response: This is essential debugging information that links client and server logs.
- Distinguish between PUT and PATCH: PUT is a full replacement (missing fields become null), PATCH is a partial update (only sent fields change). PATCH is appropriate for most update requests.
- Document with OpenAPI (Swagger): Auto-generating API specs from code prevents inconsistencies between documentation and the actual API.
Was this article helpful?
Thanks for your feedback!