REST API Design: Building APIs That Developers Love (2026)
A great API is one that developers enjoy using. It's intuitive, consistent, well-documented, and forgiving of mistakes. Here's how to build one.
Core Design Principles
1. Consistency: If /users returns {id, name}, then /posts should use the same format
2. Simplicity: Fewer endpoints doing more (filtering, sorting, pagination) is better
3. Predictability: Same input → same output, every time
4. Discoverability: The API should guide developers (links, errors with next steps)
5. Performance: Default to efficient; allow opt-in for expensive operations
URL & Resource Naming
// Use NOUNS not verbs (URLs are resources, not actions):
GET /users // List users
GET /users/123 // Get specific user
POST /users // Create user
PUT /users/123 // Full update user
PATCH /users/123 // Partial update user
DELETE /users/123 // Delete user
// ❌ Bad: Verbs in URLs
// GET /getUsers
// POST /createUser
// POST /deleteUser?id=123
// ❌ Bad: CRUD in URL
// POST /users/create
// PUT /users/update/123
// Nesting — when to nest vs flatten:
// Rule: Nest only if the child has NO meaning without the parent:
GET /users/123/orders // ✅ Orders belong to a specific user
GET /orders?userId=123 // Also valid (preferred for many results)
// Avoid deep nesting (>2 levels):
// GET /users/123/orders/456/items/789 // ❌ Too deep!
// GET /items?orderId=456 // ✅ Flatten it
// Pluralize resource names consistently:
/users // NOT /user/
/posts // NOT /post/
/categories // NOT /category/
// Use kebab-case for URL paths:
/user-profiles // NOT /userProfiles or /user_profiles
/pending-orders // NOT /pendingOrders
/api/v1/active-users // Clear versioning + clear path
Request & Response Format
// Standard response envelope (always wrap!):
{
"success": true,
"data": {
"id": "uuid-123",
"name": "Alice",
"email": "alice@example.com"
},
"meta": {
"requestId": "req-abc123",
"timestamp": "2026-06-12T04:33:00Z"
}
}
// List response with pagination:
{
"success": true,
"data": [ /* items */ ],
"meta": {
"page": 1,
"limit": 20,
"total": 147,
"totalPages": 8,
"hasNext": true,
"hasPrev": false
}
}
// Error response (consistent structure across ALL errors):
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The request data is invalid",
"details": [
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "password", "message": "Must be at least 12 characters" }
],
"incidentId": "err-def456", // For support lookup
"documentationUrl": "https://docs.api.com/errors/validation",
"retryable": false // Can client retry this request?
},
"meta": {
"requestId": "req-abc123",
"timestamp": "2026-06-12T04:33:00Z"
}
}
Query Parameters for Filtering, Sorting & Pagination
// Filtering: Use query parameters, never nested objects in URLs:
GET /api/v1/posts?status=published&author=alice&tag=javascript&year=2026
// Implementation:
function parseFilters(query, allowedFilters) {
const filters = {};
for (const [key, value] of Object.entries(query)) {
if (!allowedFilters.includes(key)) continue;
// Support comma-separated values for array params:
if (typeof value === 'string' && value.includes(',')) {
filters[key] = value.split(',');
} else {
filters[key] = value;
}
}
return filters;
}
// Allowed filters per endpoint:
const postFilters = ['status', 'author', 'tag', 'year', 'search', 'after', 'before'];
// Sorting: sort=field:direction (default direction varies by field):
GET /api/v1/posts?sort=createdAt:desc,title:asc
// Parse sorting:
function parseSort(sortParam, allowedFields) {
const defaults = { field: 'createdAt', direction: 'desc' };
if (!sortParam) return defaults;
const [field, direction = 'desc'] = sortParam.split(':');
if (!allowedFields.includes(field)) return defaults; // Ignore invalid fields!
return {
field,
direction: ['asc', 'desc'].includes(direction) ? direction : 'desc'
};
}
// Pagination: Cursor-based (preferred for large datasets) OR offset-based:
// Offset-based (simple, good for small datasets):
GET /api/v1/posts?page=2&limit=20
// Cursor-based (efficient for large/real-time datasets):
GET /api/v1/posts?limit=20&cursor=eyJpZCI6MTAwfQ==
// Response includes cursor for next page:
{
"data": [...],
"pagination": {
"next": "eyJpZCI6MTIwfQ==", // Base64 cursor for next page
"prev": null, // No previous page on first page
"remaining": 127 // Items remaining after this page
}
}
// Field selection (reduce payload size):
GET /api/v1/posts?fields=id,title,createdAt,author.name
// Only return specified fields (supports dot notation for nested!)
HTTP Methods & Status Codes
// Method usage rules:
// GET — Must be idempotent + safe (no side effects). Never use body.
// PUT — Idempotent (calling twice = calling once)
// POST — Not idempotent (creates new resource each time)
// PATCH — Not necessarily idempotent (partial updates)
// DELETE — Idempotent (deleting twice = deleting once)
// Status codes — use them correctly!
// 2xx Success:
200 OK // Standard success (GET, PATCH, PUT)
201 Created // Resource created (POST). Include Location header!
202 Accepted // Async processing started. Return job ID/status endpoint.
204 No Content // Success but no body (DELETE often uses this)
// 3xx Redirect:
301 Moved Permanently // Permanent redirect (use new URL)
304 Not Modified // Client cache is still valid (use ETag/If-None-Match)
// 4xx Client Errors:
400 Bad Request // Malformed syntax, invalid data
401 Unauthorized // Not authenticated (no token / bad token)
403 Forbidden // Authenticated but not allowed (wrong role)
404 Not Found // Resource doesn't exist
405 Method Not Allowed // Wrong HTTP method for this endpoint
409 Conflict // State conflict (duplicate email, concurrent edit)
422 Unprocessable Entity // Valid format but semantic error (validation failure)
429 Too Many Requests // Rate limited. Include Retry-After header.
// 5xx Server Errors:
500 Internal Server Error // Unexpected server error (log incident ID!)
502 Bad Gateway // Upstream service unavailable
503 Service Unavailable // Temporary overload/maintenance
504 Gateway Timeout // Upstream didn't respond in time
// ⚠️ Common mistakes:
// - Using 200 for errors (breaks clients expecting success body)
// - Using 401 for "not found" (401 = auth issue, 404 = missing resource)
// - Using 500 for validation failures (use 400 or 422)
// - Forgetting 201 Location header on creation
Versioning Strategy
// Option 1: URL path versioning (most common, clearest):
/api/v1/users
/api/v2/users // Breaking change? New version.
// Option 2: Header versioning (cleaner URLs):
Accept: application/vnd.myapi.v2+json
// Option 3: Query param versioning (simplest, least favored):
/api/users?v=2
// Versioning best practices:
// - Start with v1 from day one (even if you think you won't need it)
// - Use MAJOR versions only (v1, v2) — no v1.1, v1.2 in URLs
// - Support old versions for at least 6 months after releasing new ones
// - Document breaking changes clearly
// - Non-breaking changes (new fields, new endpoints) don't need version bump
Authentication & Rate Limiting
// API key pattern (for server-to-server):
// Headers:
X-API-Key: sk_live_abc123
X-Request-ID: req-unique-id // Client-generated for tracing
// Bearer token (OAuth2/JWT):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
// Rate limiting headers (include in EVERY response):
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1718187600 // Unix timestamp when limit resets
Retry-After: 60 // On 429: seconds until retry
// Implement rate limiting:
const rateLimits = {
authenticated: { windowMs: 60 * 1000, max: 1000 }, // 1000/min for logged-in
unauthenticated: { windowMs: 60 * 1000, max: 100 }, // 100/min for anonymous
sensitive: { windowMs:15 * 60 * 1000, max: 5 }, // Auth endpoints: 5/15min
};
What API design principle matters most to you? What's the worst API you've had to work with?
Follow @armorbreak for more practical developer guides.
Top comments (0)