REST API Design: Building APIs Developers Love (2026)
A great API is a pleasure to use. A terrible one makes developers want to quit. Here's how to build the former.
Core Design Principles
1. Consistency — If /users returns {id, name}, then /posts should return {id, title}
Same naming, same structure, same patterns everywhere
2. Predictability — Same input → Same output
No magic behavior, no hidden side effects
3. Simplicity — Fewer endpoints doing more (right)
vs many tiny endpoints that force N+1 requests
4. Self-documentation — The API should explain itself
Good URLs, good error messages, good response structure
5. Performance by default — Pagination, filtering, field selection built in
Don't make developers fetch everything to find one item
URL Design
// Resources (nouns, not verbs!)
GET /api/users // List users
POST /api/users // Create user
GET /api/users/123 // Get specific user
PUT /api/users/123 // Full update
PATCH /api/users/123 // Partial update
DELETE /api/users/123 // Delete user
// Nested resources (keep it shallow — max 2-3 levels)
GET /api/users/123/posts // User's posts
GET /api/users/123/posts/456 // Specific post of specific user
// NOT: /api/users/123/posts/456/comments/789/replies (too deep!)
// Actions (when CRUD doesn't fit)
POST /api/users/123/activate // Activate account
POST /api/posts/456/publish // Publish draft
POST /api/orders/567/cancel // Cancel order
// Search & Filter
GET /api/users?role=admin&status=active
GET /api/posts?tag=javascript&sort=-created_at&page=1&limit=20
// ❌ Bad URL patterns:
/api/getUsers // Verbs in URL
/api/user-list // Inconsistent naming
/Users // Capital letters (case-sensitive!)
/api/users/get/123 // Unnecessary nesting
/v1/createUser // Verb as resource name
Request/Response Format
// Standard response envelope:
{
"data": [...], // The actual data (array or object)
"meta": { // Metadata for pagination, etc.
"page": 1,
"limit": 20,
"total": 150,
"total_pages": 8
},
"links": { // Navigation links (HATEOAS-lite)
"self": "/api/users?page=1",
"next": "/api/users?page=2",
"prev": null
}
}
// Single item response:
{
"data": {
"id": "usr_abc123",
"name": "Alice",
"email": "alice@example.com",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-06-01T08:22:00Z"
}
}
// Error response (ALWAYS consistent!):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_EMAIL"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"code": "PASSWORD_TOO_SHORT"
}
],
"request_id": "req_abc123" // For support debugging!
}
}
// HTTP Status Codes (use them correctly!)
200 OK // Success (GET, PATCH, PUT)
201 Created // Resource created (POST)
204 No Content // Success but no body (DELETE)
400 Bad Request // Client error (validation, bad syntax)
401 Unauthorized // Not authenticated (no token/invalid token)
403 Forbidden // Authenticated but not allowed
404 Not Found // Resource doesn't exist
409 Conflict // State conflict (duplicate email, etc.)
422 Unprocessable // Valid format but semantic error
429 Too Many Requests // Rate limited
500 Internal Server Error // Something went wrong (log the details!)
502/503/504 // Upstream/gateway issues
Query Parameters Convention
// Filtering:
GET /api/products?category=electronics&price_min=100&price_max=1000
// Multiple values for same field:
GET /api/products?color=red&color=blue // AND logic (both red AND blue)
GET /api/products?status=pending,approved // OR logic within field
// Sorting:
GET /api/users?sort=name // Ascending
GET /api/users?sort=-created_at // Descending (- prefix)
GET /api/users?sort=name,-created_at // Multi-sort
// Pagination:
GET /api/users?page=1&limit=20 // Offset-based
GET /api/users?cursor=abc123&limit=20 // Cursor-based (better for infinite scroll!)
// Field selection (reduce payload size):
GET /api/users?fields=id,name,email // Only return these fields
GET /api/posts?fields=id,title,author.name // Nested field selection!
// Search:
GET /api/users?q=alice // Full-text search
GET /api/products?search=wireless+mouse // Alternative search param name
// Include related resources:
GET /api/posts/123?include=author,tags // Eager load relations
Implementation Example (Express.js)
import express from 'express';
import { z } from 'zod'; // Schema validation
const app = express();
app.use(express.json());
// Validation middleware factory
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
request_id: req.id,
}
});
}
Object.assign(req, result.data); // Validated data
next();
};
}
// Route definitions
const createUserSchema = z.object({
body: z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(128),
name: z.string().min(1).max(100).trim(),
role: z.enum(['user', 'creator']).optional().default('user'),
}),
});
app.post('/api/users', validate(createUserSchema), async (req, res) => {
try {
const { email, password, name, role } = req.body;
// Check duplicate
const existing = await db.users.findByEmail(email);
if (existing) {
return res.status(409).json({
error: { code: 'DUPLICATE_EMAIL', message: 'Email already registered', request_id: req.id }
});
}
// Create user
const user = await db.users.create({ email, password: await hash(password), name, role });
res.status(201).json({ data: serialize(user) });
} catch (err) {
logger.error('Create user error', { err, requestId: req.id });
res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred', request_id: req.id }
});
}
});
// List endpoint with all features
app.get('/api/users', async (req, res) => {
const { page = 1, limit = 20, sort = 'name', role, q } = req.query;
const filter = {};
if (role) filter.role = role;
if (q) filter.search = q;
const { items, total } = await db.users.find({
...filter,
sort,
page: Math.max(1, parseInt(page)),
limit: Math.min(100, parseInt(limit)), // Cap at 100
});
res.json({
data: items.map(serialize),
meta: { page: parseInt(page), limit: parseInt(limit), total, total_pages: Math.ceil(total / limit) },
links: {
self: `/api/users?page=${page}&limit=${limit}`,
next: total > page * limit ? `/api/users?page=${parseInt(page)+1}&limit=${limit}` : null,
}
});
});
API Versioning Strategy
// Option 1: URL Path Versioning (most common, clearest)
app.use('/v1/users', v1UserRoutes);
app.use('/v2/users', v2UserRoutes);
// Pros: Clear, cacheable per version, works with CDNs
// Cons: URL clutter
// Option 2: Header Versioning
app.use((req, res, next) => {
const version = req.headers['api-version'] || 'v1';
req.apiVersion = version;
next();
});
// Pros: Clean URLs
// Cons: Harder to test in browser, not cache-friendly
// Option 3: Content Negotiation
app.get('/users', (req, res) => {
const accept = req.headers.accept || '';
if (accept.includes('vnd.api.v2+json')) {
return v2Handler(req, res);
}
return v1Handler(req, res);
});
// Pros: RESTful purist approach
// Cons: Complex, confusing for beginners
// My recommendation: Start with URL path versioning.
// It's the simplest and most widely understood.
What's your biggest API design pet peeve? What makes an API delightful to work with?
Follow @armorbreak for more practical developer guides.
Top comments (0)