Designing a REST API That Developers Actually Like Using
API design is UX for developers. Make it delightful.
Core Principles
1. Consistent — Same patterns everywhere
2. Predictable — No surprises
3. Self-documenting — Names explain themselves
4. Versioned — Changes don't break clients
5. Secure — Auth, validation, rate limiting
URL Design
// ✅ Good: Nouns, not verbs
GET /api/users // List users
GET /api/users/123 // Get specific user
POST /api/users // Create user
PUT /api/users/123 // Full update
PATCH /api/users/123 // Partial update
DELETE /api/users/123 // Delete user
// ❌ Bad: Verbs in URLs
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser/123
// ✅ Good: Nested resources (1-2 levels max)
GET /api/users/123/orders // User's orders
GET /api/users/123/orders/456 // Specific order
// ❌ Bad: Too deep
GET /api/users/123/orders/456/items/789/reviews
Request/Response Format
Standard Response Envelope
// Success response
{
"data": { /* resource or array */ },
"meta": {
"page": 1,
"per_page": 20,
"total": 150,
"total_pages": 8
}
}
// Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "Must be a positive integer" }
]
},
"request_id": "req_abc123"
}
Filtering, Sorting, Pagination
// Filter
GET /api/users?role=admin&active=true&created_after=2026-01-01
// Sort
GET /api/users?sort=created_at&order=desc
GET /api/users?sort=name,age&order=asc,desc // Multi-sort
// Pagination
GET /api/users?page=2&per_page=20
// Or cursor-based (better for large datasets):
GET /api/users?cursor=eyJpZCI6MTIzfQ==&limit=20
// Field selection (reduce payload)
GET /api/users?fields=id,name,email
GET /api/users/123?fields=id,name,orders(count)
Search
// Search endpoint
GET /api/search?q=nodejs&type=articles&tags=javascript,tutorial&page=1
// Response
{
"data": [
{
"id": "art_1",
"title": "Node.js Guide",
"highlight": {
"title": "<em>Node.js</em> Guide", // Highlighted matches
"excerpt": "Learn <em>Node.js</em> from scratch..."
},
"_score": 0.95,
"_type": "article"
}
],
"meta": { "total": 42, "query": "nodejs", "took_ms": 12 }
}
Authentication
// Bearer token (most common for APIs)
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
// API key (for server-to-server)
X-API-Key: ak_live_abc123
// Never send tokens in URL parameters!
// ❌ GET /api/users?token=eyJhbG...
// ✅ Use Authorization header always
Rate Limiting
// Response headers (always include!)
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1740000000
Retry-After: 60
// When rate limited:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1740000100
Retry-After: 45
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 45 seconds."
}
}
Idempotency & Safety
| Method | Safe | Idempotent | Meaning |
|---|---|---|---|
| GET | ✅ | ✅ | Read only |
| HEAD | ✅ | ✅ | Headers only |
| OPTIONS | ✅ | ✅ | Capabilities |
| PUT | ❌ | ✅ | Replace entirely |
| DELETE | ❌ | ✅ | Remove (same result if called again) |
| POST | ❌ | ❌ | Create new (different each time) |
| PATCH | ❌ | ❌ | Partial update |
Real Implementation Example
const express = require('express');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const app = express();
app.use(helmet());
app.use(express.json({ limit: '10kb' }));
// Rate limiting
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
}));
// Request ID middleware
app.use((req, res, next) => {
req.id = crypto.randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
});
// Routes
app.get('/api/v1/users', async (req, res) => {
try {
const { page = 1, per_page = 20, sort = 'created_at', order = 'desc', role } = req.query;
const result = await User.findAll({
where: role ? { role } : {},
limit: parseInt(per_page),
offset: (parseInt(page) - 1) * parseInt(per_page),
order: [[sort, order.toUpperCase()]],
});
res.json({
data: result.rows,
meta: {
page: parseInt(page),
per_page: parseInt(per_page),
total: result.count,
total_pages: Math.ceil(result.count / per_page),
}
});
} catch (err) {
res.status(500).json({ error: { code: 'INTERNAL', message: err.message }, request_id: req.id });
}
});
app.post('/api/v1/users', async (req, res) => {
try {
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(422).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: error.details },
request_id: req.id
});
}
const user = await User.create(value);
res.status(201).location(`/api/v1/users/${user.id}`).json({ data: user });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({ error: { code: 'DUPLICATE', message: 'Email already exists' }, request_id: req.id });
}
res.status(500).json({ error: { code: 'INTERNAL', message: err.message }, request_id: req.id });
}
});
// 404 handler
app.use('/api/', (_req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Resource not found' } });
});
Quick Reference
| Rule | Do | Don't |
|---|---|---|
| URLs | /api/users |
/api/getUsers |
| HTTP methods | Correct semantics | POST for everything |
| Status codes | Accurate semantic codes | Always 200 |
| Errors | Structured with details | Plain text messages |
| Pagination |
page + per_page
|
Return everything |
| Versioning | /api/v1/ |
Change existing endpoints |
| Auth | Header Authorization
|
Query param token |
| Naming | snake_case | camelCase (in JSON) |
| Dates | ISO 8601 | Timestamps |
What's the best/worst API you've used? What made it great or terrible?
Follow @armorbreak for more backend content.
Top comments (0)