REST API Design: Building APIs Developers Love (2026)
A great API is a pleasure to use. A terrible one drives developers away. Here's how to design APIs that people actually want to work with.
Core Principles
1. Consistency → Same pattern everywhere, no surprises
2. Simplicity → Easy to understand, minimal cognitive load
3. Predictability → Same input always produces same output
4. Self-documentation → Clear enough to use without external docs
5. Performance → Fast responses, efficient data transfer
6. Security → Authenticated, authorized, rate-limited
7. Evolvability → Versioned, backward-compatible changes
URL Design & Resource Naming
# Use nouns (resources), not verbs:
# ❌ /getUsers /createUser /deleteUser
# ✅ GET /users List users
# ✅ POST /users Create user
# ✅ GET /users/123 Get specific user
# ✅ PUT /users/123 Replace user
# ✅ PATCH /users/123 Partial update
# ✅ DELETE /users/123 Delete user
# Plural nouns for collections:
/users # Collection of all users
/users/123 # Specific resource
# Nested resources (relationships):
/users/123/orders # Orders belonging to user 123
/users/123/orders/456 # Specific order of specific user
# Don't nest too deep (max 2-3 levels):
# ✅ /users/123/orders
# ❌ /users/123/orders/456/items/789/reviews
# Query parameters for filtering/sorting/pagination:
GET /users?role=admin&status=active&sort=name&order=asc&page=1&limit=20
# Not path segments: /users/role/admin/status/active (too complex)
# Search as a resource action:
GET /users/search?q=john&fields=id,name,email
# Actions (when CRUD doesn't fit — use verbs sparingly):
POST /users/123/activate # State change (not CRUD)
POST /orders/123/cancel # Cancel an order
POST /password/reset # Initiate password reset
HTTP Methods & Status Codes
// HTTP Methods — use them correctly:
// GET — Retrieve data (safe, idempotent, cacheable)
app.get('/api/products', listProducts);
app.get('/api/products/:id', getProduct);
// POST — Create new resource (not idempotent)
app.post('/api/products', createProduct); // Returns 201 with Location header
// PUT — Full replacement (idempotent)
app.put('/api/products/:id', replaceProduct);
// PATCH — Partial update (idempotent if implemented correctly)
app.patch('/api/products/:id', updateProduct);
// DELETE — Remove resource (idempotent)
app.delete('/api/products/:id', deleteProduct);
// Status codes — be precise!
// 2xx Success:
res.status(200).json(data); // OK (standard success)
res.status(201).json(createdData); // Created (include Location header)
res.status(204).send(); // No Content (successful delete)
res.status(202).json({ taskId }); // Accepted (async processing started)
// 3xx Redirection:
res.status(301).redirect('https://...'); // Permanent redirect
res.status(304).send(); // Not Modified (ETag match)
// 4xx Client Errors:
res.status(400).json({ error: 'Bad Request', details: validationErrors });
res.status(401).json({ error: 'Unauthorized' }); // Missing or invalid auth
res.status(403).json({ error: 'Forbidden' }); // Authenticated but not allowed
res.status(404).json({ error: 'Not Found' });
res.status(409).json({ error: 'Conflict' }); // Duplicate resource
res.status(422).json({ error: 'Unprocessable Entity', fields: [...] });
res.status(429).json({ error: 'Too Many Requests', retryAfter: 60 });
// 5xx Server Errors:
res.status(500).json({ error: 'Internal Server Error' });
res.status(502).json({ error: 'Bad Gateway' });
res.status(503).json({ error: 'Service Unavailable' });
Response Format & Pagination
// Standard response envelope (consistent structure!):
// Success response:
{
"data": { /* the actual resource(s) */ },
"meta": {
"requestId": "req_abc123",
"timestamp": "2026-06-05T10:30:00Z"
}
}
// List response with pagination:
{
"data": [
{ "id": 1, "name": "Product A" },
{ "id": 2, "name": "Product B" }
],
"pagination": {
"page": 1,
"limit": 20,
"totalItems": 150,
"totalPages": 8,
"hasNext": true,
"hasPrev": false,
"nextPageUrl": "/products?page=2&limit=20",
"prevPageUrl": null
},
"meta": {
"requestId": "req_def456"
}
}
// Error response (ALWAYS consistent format!):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Must be at least 8 characters" }
],
"incidentId": "inc_789xyz", // For support lookup!
"documentationUrl": "https://docs.example.com/errors/VALIDATION_ERROR"
}
}
// Pagination implementation:
async function listProducts(req, res) {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
const offset = (page - 1) * limit;
const [items, total] = await Promise.all([
Product.find().skip(offset).limit(limit),
Product.countDocuments()
]);
res.json({
data: items,
pagination: {
page,
limit,
totalItems: total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
}
});
}
Authentication & Rate Limiting
// API Key authentication (for server-to-server):
const apiAuth = async (req, res, next) => {
const key = req.headers['x-api-key'];
if (!key) return res.status(401).json({ error: 'API key required' });
const apiKey = await ApiKey.findOne({ key, active: true })
.populate('owner');
if (!apiKey) return res.status(401).json({ error: 'Invalid API key' });
req.apiKey = apiKey;
req.user = apiKey.owner;
next();
};
// JWT authentication (for user-facing APIs):
const jwtAuth = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
try {
const payload = jwt.verify(authHeader.slice(7), process.env.JWT_SECRET);
req.user = await User.findById(payload.sub).select('-password');
if (!req.user) return res.status(401).json({ error: 'User not found' });
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Rate limiting (per API key and per IP):
const rateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per key/IP
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.apiKey?.key || req.ip,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
});
},
});
// Apply to all routes:
app.use('/api/', rateLimiter);
app.use('/api/public', publicRoutes); // Lower limits for unauthenticated
app.use('/api/', jwtAuth, authenticatedRoutes); // Higher limits for auth'd users
Versioning Strategy
# URL versioning (most common, clearest):
/api/v1/users
/api/v2/users # Different structure, breaking change
# Header versioning (cleaner URLs):
Accept: application/vnd.myapi.v2+json
GET /api/users
# Best practices:
# - Start with v1 from day one (even if you think you won't need it)
# - Support old versions for at least 6-12 months after releasing new one
# - Document deprecation timeline clearly
# - Never make breaking changes within a version
# When to bump versions:
# MAJOR: Remove or rename fields, change URL structure, new required params
# MINOR: Add optional fields, add new endpoints (backward compatible)
# PATCH: Bug fixes, documentation updates
What's the best/worst API you've worked with? What made it great or terrible?
Follow @armorbreak for more practical developer guides.
Top comments (0)