REST API Design: Building APIs That Developers Love (2026)
A great API is one that developers enjoy using. Here's how to design RESTful APIs that are intuitive, consistent, and a joy to integrate.
Core Principles
Good API design is about:
1. Consistency — Same patterns everywhere
2. Simplicity — Obvious how to use, minimal learning curve
3. Predictability — Same input → same output, always
4. Discoverable — Self-documenting, easy to explore
The Golden Rule:
If a developer needs to read your documentation to guess the URL,
your API design has already failed.
URL Design: The Foundation
// === Resource Naming (Nouns, NOT verbs!) ===
// ❌ Verbs in URLs (anti-pattern):
GET /getUsers
POST /createUser
DELETE /deleteUser/123
POST /updateUser/123
// ✅ Nouns + HTTP methods (RESTful):
GET /users // List all users
GET /users/123 // Get specific user
POST /users // Create new user
PUT /users/123 // Replace user entirely
PATCH /users/123 // Partial update
DELETE /users/123 // Delete user
// === Nesting (relationships) ===
// Use nesting for parent-child relationships:
GET /users/123/orders // Orders belonging to user 123
POST /users/123/orders // Create order for user 123
GET /users/123/orders/456 // Specific order of specific user
// But don't nest too deep (max 2-3 levels):
// ❌ Too deep: GET /users/123/orders/456/items/789
// ✅ Flatten: GET /orders/456/items/789 (you know the order ID)
// === Query Parameters vs Path Params ===
// Path params = Resource identification (required)
GET /users/123 // User 123 is the resource
GET /posts/2026-06-05 // Posts from this date
// Query params = Filtering, sorting, pagination (optional)
GET /users?role=admin&status=active // Filter
GET /posts?sort=-created_at&page=2&limit=20 // Sort + paginate
GET /products?search=wireless+mouse // Search
GET /orders?fields=id,total,status // Field selection (sparse fields)
// === Plural vs Singular ===
// Use plural for collection endpoints (industry standard):
/users // Collection of users
/users/123 // Single user from collection
/posts // Collection of posts
HTTP Methods & Status Codes
// === HTTP Methods (CRUD mapping) ===
// GET — Read (safe, idempotent, cacheable)
// POST — Create (not safe, not idempotent)
// PUT — Replace (idempotent)
// PATCH — Partial update (not idempotent)
// DELETE — Remove (idempotent)
// Idempotent = Calling it multiple times = calling it once
// GET /users/123 → same result every call ✅
// POST /users → creates new user each call ❌ (different users!)
// PUT /users/123 → same state after each call ✅
// DELETE /users/123 → gone after first, still "gone" after second ✅
// === Status Codes (use them correctly!) ===
// 2xx — Success
200 OK // Standard success response
201 Created // Resource created (include Location header!)
204 No Content // Success but no body (DELETE often uses this)
// 3xx — Redirection
304 Not Modified // For caching (If-None-Match / If-Modified-Since)
// 4xx — Client Errors (THE developer's fault)
400 Bad Request // Malformed syntax, invalid data
401 Unauthorized // Not authenticated (no token / bad token)
403 Forbidden // Authenticated but not allowed
404 Not Found // Resource doesn't exist
405 Method Not Allowed // Wrong HTTP method for this endpoint
409 Conflict // Resource conflict (duplicate email, etc.)
422 Unprocessable Entity // Valid format but business rule violation
429 Too Many Requests // Rate limited
// 5xx — Server Errors (YOUR fault)
500 Internal Server Error // Generic server error (log details!)
502 Bad Gateway // Upstream service error
503 Service Unavailable // Temporarily down (maintenance mode)
504 Gateway Timeout // Upstream took too long
// === Practical Express implementation ===
app.get('/api/users/:id', async (req, res) => {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'USER_NOT_FOUND', message: 'User not found' }
});
}
// Support sparse field selection
if (req.query.fields) {
const fields = req.query.fields.split(',');
return res.json(pick(user, fields));
}
res.status(200).json({ data: user });
} catch (err) {
logger.error('Get user failed', err);
res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }
});
}
});
Request & Response Format
// === Standard Request Structure ===
// Headers:
Content-Type: application/json
Authorization: Bearer <jwt_token>
X-Request-ID: uuid-here // For tracing
X-API-Version: 2024-06-01 // Versioning via header
Accept: application/json
// Body (for POST/PATCH):
{
"name": "Alice",
"email": "alice@example.com",
"preferences": {
"theme": "dark"
}
}
// === Standard Response Structure (envelope pattern) ===
// Success response:
{
"data": { // Actual data ALWAYS inside "data"
"id": "usr_abc123",
"name": "Alice",
"email": "alice@example.com"
},
"meta": { // Metadata for pagination, etc.
"request_id": "req_xyz789"
}
}
// List response (with pagination):
{
"data": [
{ "id": "usr_1", "name": "Alice" },
{ "id": "usr_2", "name": "Bob" }
],
"meta": {
"page": 1,
"per_page": 20,
"total_count": 150,
"total_pages": 8,
"has_next": true,
"has_prev": false,
"request_id": "req_xyz789"
}
}
// Error response (consistent structure!):
{
"error": {
code: "VALIDATION_ERROR", // Machine-readable error code
message: "Email address is invalid", // Human-readable
details: [ // Specific field errors
{
"field": "email",
"message": "Must be a valid email address",
"code": "INVALID_EMAIL"
}
]
},
"meta": {
"request_id": "req_xyz789" // Always include for debugging!
}
}
Pagination, Filtering & Sorting
// === Pagination (cursor-based for large datasets) ===
app.get('/api/posts', async (req, res) => {
const { cursor, limit = 20 } = req.query;
const posts = await postService.findPaginated({
cursor, // Opaque token from previous page's "next_cursor"
limit: Math.min(parseInt(limit), 100), // Cap at 100
});
res.json({
data: posts.items,
meta: {
next_cursor: posts.nextCursor || null, // null = no more pages
has_next: !!posts.nextCursor,
per_page: posts.items.length,
}
});
});
// Client flow:
// GET /api/posts?limit=20 // First page
// Response: { next_cursor: "eyJpZCI6MjB9" }
// GET /api/posts?limit=20&cursor=eyJpZCI6MjBfQ== // Next page
// === Filtering ===
// Standard filter operators:
GET /api/products?category=electronics&price_gte=50&price_lte=500&sort=price&order=asc
// Implementation:
function buildFilters(query) {
const filters = {};
if (query.category) filters.category = query.category;
if (query.price_gte) filters.price = { ...filters.price, $gte: parseFloat(query.price_gte) };
if (query.price_lte) filters.price = { ...filters.price, $lte: parseFloat(query.price.lte) };
if (query.status) filters.status = query.status.split(','); // Multiple values
return filters;
}
// === Sorting ===
// Allow sort on any indexed field:
GET /api/users?sort=created_at&order=desc
GET /api/users?sort=name&order=asc
// Negative prefix for descending (common convention):
GET /api/users?sort=-created_at // Descending by created_at
GET /api/users?sort=name,+email // Ascending by name, then email (tiebreaker)
// === Search ===
GET /api/products?q=wireless mouse
// Full-text search across name + description fields
Versioning & Evolution
// === URL Versioning (most common) ===
/api/v1/users // Version 1
/api/v2/users // Version 2 (breaking changes)
// Support multiple versions simultaneously during migration:
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/api/v1', v1Router); // Existing clients keep working
app.use('/api/v2', v2Router); // New clients get improvements
// === Header Versioning (cleaner URLs) ===
Accept: application/vnd.api.v2+json
// More RESTful but harder to test in browser
// === Non-Breaking Changes (additive, backward compatible) ===
✅ Add new optional fields to responses
✅ Add new optional query parameters
✅ Add new endpoints (don't modify existing ones)
✅ Add new event types to webhooks
// === Breaking Changes (require version bump) ===
❌ Remove or rename fields
❌ Change response structure
❌ Change authentication method
❌ Modify validation rules to reject previously valid input
❌ Change URL paths or HTTP methods
// Deprecation strategy:
app.get('/api/v1/legacy-endpoint', async (req, res) => {
res.set('Deprecation', 'true');
res.set('Sunset', '2027-01-01T00:00:00Z'); // When it will be removed
res.set('Link', '</api/v2/new-endpoint>; rel="successor"');
// ... serve response ...
});
What's the best/worst API you've worked with? What API design principle do you feel strongest about?
Follow @armorbreak for more practical developer guides.
Top comments (0)