REST API Design: Best Practices and Common Pitfalls
Designing a great REST API is both an art and a science. A well-designed API is intuitive, consistent, and scalable. This comprehensive guide covers everything from basic principles to advanced patterns for building production-ready RESTful APIs.
What is REST?
REST (Representational State Transfer) is an architectural style for designing networked applications. It relies on stateless, client-server communication using standard HTTP methods and follows a set of constraints that make APIs predictable and maintainable.
REST Principles
- Client-Server: Separation of concerns between UI and data storage
- Stateless: Each request contains all information needed to process it
- Cacheable: Responses explicitly indicate if they can be cached
- Uniform Interface: Consistent way to interact with resources
- Layered System: Client doesn't know if connected directly to server or intermediary
- Code on Demand (Optional): Server can extend client functionality
HTTP Methods
REST APIs use standard HTTP methods to perform CRUD operations:
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
GET | Retrieve resource(s) | ✅ Yes | ✅ Yes |
POST | Create new resource | ❌ No | ❌ No |
PUT | Update/replace entire resource | ✅ Yes | ❌ No |
PATCH | Partial update of resource | ❌ No | ❌ No |
DELETE | Remove resource | ✅ Yes | ❌ No |
Resource Naming Conventions
Use Nouns, Not Verbs
❌ Bad:
GET /getUsers
POST /createUser
PUT /updateUser/123
DELETE /deleteUser/123✅ Good:
GET /users
POST /users
PUT /users/123
DELETE /users/123Use Plural Nouns for Collections
❌ Bad:
GET /user
GET /user/123
POST /user✅ Good:
GET /users
GET /users/123
POST /usersNested Resources
// Get all posts by a user
GET /users/123/posts
// Get a specific post by a user
GET /users/123/posts/456
// Create a new post for a user
POST /users/123/posts
// Get comments on a post
GET /users/123/posts/456/comments
// Avoid deep nesting (max 2-3 levels)
❌ GET /users/123/posts/456/comments/789/replies/101Use Lowercase and Hyphens
❌ Bad:
GET /userProfiles
GET /User_Profiles
GET /USERS✅ Good:
GET /user-profiles
GET /users
GET /blog-postsHTTP Status Codes
Always use appropriate HTTP status codes to indicate the result of an operation:
Success Codes (2xx)
200 OK- Successful GET, PUT, PATCH, or DELETE201 Created- Successful POST that creates a resource204 No Content- Successful request with no response body
Client Error Codes (4xx)
400 Bad Request- Invalid request syntax or parameters401 Unauthorized- Authentication required or failed403 Forbidden- Authenticated but not authorized404 Not Found- Resource doesn't exist409 Conflict- Request conflicts with current state422 Unprocessable Entity- Validation errors429 Too Many Requests- Rate limit exceeded
Server Error Codes (5xx)
500 Internal Server Error- Generic server error502 Bad Gateway- Invalid response from upstream server503 Service Unavailable- Server temporarily unavailable504 Gateway Timeout- Upstream server timeout
Error Handling
Consistent Error Response Format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request parameters",
"details": [
{
"field": "email",
"message": "Email format is invalid"
},
{
"field": "age",
"message": "Age must be between 18 and 120"
}
],
"timestamp": "2025-10-24T10:30:00Z",
"path": "/api/users",
"requestId": "req-123-456"
}
}Implementation Example (Express.js)
// Error handler middleware
app.use((err, req, res, next) => {
const error = {
code: err.code || 'INTERNAL_ERROR',
message: err.message || 'An unexpected error occurred',
timestamp: new Date().toISOString(),
path: req.path,
requestId: req.id
};
// Add validation details if present
if (err.validationErrors) {
error.details = err.validationErrors.map(e => ({
field: e.field,
message: e.message
}));
}
// Log error (but don't expose internal details to client)
console.error('API Error:', {
...error,
stack: err.stack
});
// Send appropriate status code
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ error });
});Filtering, Sorting, and Pagination
Filtering
// Filter by single field
GET /users?status=active
// Filter by multiple fields
GET /users?status=active&role=admin
// Filter with comparison operators
GET /products?price_min=10&price_max=100
// Filter by date range
GET /orders?created_after=2025-01-01&created_before=2025-12-31Sorting
// Sort by single field (ascending)
GET /users?sort=name
// Sort descending (use minus prefix)
GET /users?sort=-created_at
// Sort by multiple fields
GET /users?sort=last_name,first_name
// Sort with explicit direction
GET /users?sort=name:asc,created_at:descPagination
// Offset-based pagination
GET /users?page=2&limit=20
// Cursor-based pagination (better for large datasets)
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
// Response format
{
"data": [...],
"pagination": {
"total": 1000,
"page": 2,
"limit": 20,
"totalPages": 50,
"hasNext": true,
"hasPrev": true,
"nextCursor": "eyJpZCI6MTQzfQ",
"prevCursor": "eyJpZCI6MTAzfQ"
},
"links": {
"self": "/users?page=2&limit=20",
"first": "/users?page=1&limit=20",
"prev": "/users?page=1&limit=20",
"next": "/users?page=3&limit=20",
"last": "/users?page=50&limit=20"
}
}Versioning Strategies
1. URL Path Versioning (Most Common)
GET /api/v1/users
GET /api/v2/users
// Pros: Clear, easy to implement, cache-friendly
// Cons: Multiple endpoints to maintain2. Header Versioning
GET /api/users
Accept: application/vnd.myapi.v2+json
// Pros: Clean URLs
// Cons: Less visible, harder to test3. Query Parameter Versioning
GET /api/users?version=2
// Pros: Easy to implement
// Cons: Can be overlooked, pollutes query parametersAuthentication & Authorization
Bearer Token Authentication
// Request
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// Implementation
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required'
}
});
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Token is invalid or expired'
}
});
}
};Rate Limiting
// Rate limit headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1635724800
// When rate limit exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1635724800
Retry-After: 3600
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Please try again later.",
"retryAfter": 3600
}
}
// Implementation with express-rate-limit
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests from this IP'
}
},
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);HATEOAS (Hypermedia)
Include links to related resources in responses:
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"_links": {
"self": {
"href": "/users/123"
},
"posts": {
"href": "/users/123/posts"
},
"followers": {
"href": "/users/123/followers"
},
"following": {
"href": "/users/123/following"
}
}
}Caching
Cache-Control Headers
// Cache for 1 hour
Cache-Control: public, max-age=3600
// Don't cache (for sensitive data)
Cache-Control: no-store, no-cache, must-revalidate
// Cache but revalidate
Cache-Control: public, max-age=0, must-revalidate
// Implementation
app.get('/api/users/:id', (req, res) => {
const user = getUserById(req.params.id);
// Set cache headers
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
res.set('ETag', generateETag(user));
res.json(user);
});ETags for Conditional Requests
// Client sends ETag from previous request
GET /api/users/123
If-None-Match: "33a64df551425fcc55e4d42a148795d9"
// Server responds with 304 if unchanged
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9"
// Or 200 with new data if changed
HTTP/1.1 200 OK
ETag: "686897696a7c876b7e"
{ ... new data ... }Security Best Practices
- ✅ Always use HTTPS in production
- ✅ Implement authentication and authorization
- ✅ Validate all input data
- ✅ Use rate limiting to prevent abuse
- ✅ Implement CORS properly
- ✅ Don't expose sensitive data in responses
- ✅ Use security headers (HSTS, CSP, etc.)
- ✅ Log security events and monitor for anomalies
- ❌ Never include passwords or secrets in responses
- ❌ Don't trust client-side validation alone
Documentation
OpenAPI/Swagger Documentation
openapi: 3.0.0
info:
title: User API
version: 1.0.0
description: API for managing users
paths:
/users:
get:
summary: List all users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
responses:
200:
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
format: emailConclusion
Building a great REST API requires careful consideration of design patterns, naming conventions, error handling, and security. By following these best practices, you'll create APIs that are intuitive, maintainable, and scalable. Remember that consistency is key - establish patterns early and stick to them throughout your API.
Start small, iterate based on feedback, and always prioritize developer experience. A well-designed API is a pleasure to use and will accelerate development for everyone who integrates with it.
Test Your APIs Instantly
Use our free API tester to quickly test REST endpoints with custom headers and authentication!
Try API Tester →