I’ve designed and maintained APIs that serve millions of requests daily, and I’ve also inherited APIs that made me want to flip my desk. The difference between a great API and a nightmare always comes down to the same set of design decisions made (or ignored) early on.
After building APIs for e-commerce platforms, fintech startups, and enterprise SaaS products, I’ve distilled my approach into principles that have saved my teams countless hours of debugging, breaking changes, and angry Slack messages from frontend devs.
Why API Design Matters More Than You Think
Here’s a story: At my previous company, we had an API endpoint that returned user data. Sometimes email was a string, sometimes an array (for users with multiple emails), and sometimes it was null vs missing entirely. Three different engineers had added to this endpoint over two years with no conventions.
The result? Every frontend dev had to write defensive code like this:
// The kind of code that makes you question your career choices
const email = Array.isArray(user.email)
? user.email[0]
: user.email ?? user.emails?.[0] ?? '';
Good API design prevents this entirely. Let’s talk about how.
Naming Conventions That Scale
Use Plural Nouns for Collections
✅ GET /api/users
✅ GET /api/users/123
✅ GET /api/users/123/orders
❌ GET /api/user
❌ GET /api/getUser/123
❌ GET /api/user/123/getOrders
Use Kebab-Case for Multi-Word Resources
✅ GET /api/line-items
✅ GET /api/payment-methods
✅ GET /api/shipping-addresses
❌ GET /api/lineItems
❌ GET /api/payment_methods
❌ GET /api/ShippingAddresses
Pro Tip: Whatever convention you choose, document it and enforce it. I use ESLint rules with custom patterns to catch route naming inconsistencies in PRs. Consistency trumps any specific convention.
Nest Resources to Show Relationships
GET /api/users/123/orders # Orders belonging to user 123
GET /api/orders/456/items # Items in order 456
GET /api/organizations/789/members # Members of org 789
But don’t nest deeper than 2 levels. If you find yourself writing /api/users/123/orders/456/items/789/variants, create a top-level resource instead:
GET /api/order-items/789 # Direct access when you have the ID
HTTP Methods: Using Them Correctly
| Method | Purpose | Idempotent | Request Body | Common Status Codes |
|---|---|---|---|---|
| GET | Read resource(s) | Yes | No | 200, 404 |
| POST | Create resource | No | Yes | 201, 400, 409 |
| PUT | Full replacement | Yes | Yes | 200, 404 |
| PATCH | Partial update | No* | Yes | 200, 404, 400 |
| DELETE | Remove resource | Yes | No | 204, 404 |
*PATCH can be idempotent depending on implementation, but isn’t guaranteed to be.
The PUT vs PATCH Debate
In my team, we follow this rule: PUT replaces the entire resource; PATCH modifies specific fields. Here’s the practical difference:
// PUT /api/users/123 - Replaces entire user
// Must include ALL fields; omitted fields are set to null/default
{
"name": "Michael Chen",
"email": "[email protected]",
"role": "admin",
"preferences": { "theme": "dark", "language": "en" }
}
// PATCH /api/users/123 - Updates only specified fields
// Only included fields are modified; everything else stays the same
{
"preferences": { "theme": "light" }
}
Pro Tip: In practice, most teams only need PATCH for updates. I haven’t used PUT in production in over a year. If you’re building a CRUD API, start with POST (create), GET (read), PATCH (update), DELETE (remove).
Error Handling That Doesn’t Suck
Bad error responses are the #1 source of frustration for API consumers. I’ve standardized on this format across all my APIs:
// Consistent error response structure
interface ApiError {
error: {
code: string; // Machine-readable error code
message: string; // Human-readable message
details?: ErrorDetail[]; // Field-level errors for validation
requestId: string; // For debugging/support
};
}
interface ErrorDetail {
field: string;
message: string;
code: string;
}
Real-World Error Response Examples
// 400 Bad Request - Validation error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be at least 18",
"code": "MIN_VALUE"
}
],
"requestId": "req_abc123"
}
}
// 404 Not Found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User with ID 999 not found",
"requestId": "req_def456"
}
}
// 429 Too Many Requests
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Try again in 30 seconds.",
"requestId": "req_ghi789"
}
}
Error Code Mapping
| HTTP Status | When to Use | Error Code Example |
|---|---|---|
| 400 | Invalid request body/params | VALIDATION_ERROR |
| 401 | Missing or invalid auth token | UNAUTHORIZED |
| 403 | Valid auth but insufficient permissions | FORBIDDEN |
| 404 | Resource doesn’t exist | RESOURCE_NOT_FOUND |
| 409 | Conflict (duplicate, state conflict) | CONFLICT |
| 422 | Semantically invalid (valid JSON, bad logic) | UNPROCESSABLE_ENTITY |
| 429 | Rate limit exceeded | RATE_LIMIT_EXCEEDED |
| 500 | Unexpected server error | INTERNAL_ERROR |
Pagination Done Right
I’ve used three pagination strategies in production, and each has its place:
Offset-Based (Simple, Good for Admin UIs)
// GET /api/users?page=2&limit=20
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 342,
"totalPages": 18
}
}
Cursor-Based (Best for Infinite Scroll, Real-time Data)
// GET /api/feed?cursor=eyJpZCI6MTAwfQ&limit=20
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTIwfQ",
"hasMore": true
}
}
Comparison
| Approach | Consistent Results | Performance at Scale | Supports “Jump to Page” | Complexity |
|---|---|---|---|---|
| Offset | ❌ (shifts on insert/delete) | ❌ O(n) on large tables | ✅ | Low |
| Cursor | ✅ | ✅ O(1) with index | ❌ | Medium |
| Keyset | ✅ | ✅ O(1) with index | ❌ | Medium |
Pro Tip: If you’re building anything with a feed (social, notifications, activity logs), use cursor-based pagination from day one. I’ve had to migrate from offset to cursor twice, and it’s painful every time. The frontend work is minimal — just pass the cursor from the previous response.
For PostgreSQL-backed APIs, cursor pagination with proper indexing gives you consistent sub-10ms query times regardless of how deep in the dataset the user navigates.
Versioning Strategies
After trying URL versioning, header versioning, and content negotiation, here’s my recommendation:
Use URL versioning for major versions, additive changes for everything else.
/api/v1/users # Original
/api/v2/users # Breaking change (new response shape)
Why Not Header Versioning?
# This looks clean in theory:
GET /api/users
Accept: application/vnd.myapi.v2+json
# But in practice:
# - Harder to test in browser
# - Harder to share links
# - Harder to cache
# - Most devs forget it exists
My Versioning Rules
- Adding a new field to a response → No version bump needed
- Adding a new optional parameter → No version bump needed
- Removing/renaming a field → Major version bump
- Changing a field’s type → Major version bump
- Changing error response format → Major version bump
Pro Tip: Don’t version preemptively. Start with
/api/users(no version prefix). Only add versioning when you actually need a breaking change. Many APIs never need v2.
Authentication & Authorization Patterns
Token Structure
// JWT payload - keep it minimal
interface TokenPayload {
sub: string; // User ID
email: string; // For quick lookups
roles: string[]; // For authorization
org: string; // Organization/tenant ID
iat: number; // Issued at
exp: number; // Expires at
}
Auth Header Pattern
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
API Key Authentication (for Service-to-Service)
// Middleware example
async function authenticateApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
error: { code: 'MISSING_API_KEY', message: 'X-API-Key header required' }
});
}
// Hash the key and look up in database
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await db.apiKeys.findByHash(hashedKey);
if (!keyRecord || keyRecord.revokedAt) {
return res.status(401).json({
error: { code: 'INVALID_API_KEY', message: 'Invalid or revoked API key' }
});
}
req.apiClient = keyRecord.client;
next();
}
Rate Limiting
Every production API needs rate limiting. Here’s my go-to implementation:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
analytics: true,
});
async function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
const identifier = req.apiClient?.id ?? req.ip;
const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
// Always include rate limit headers
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', reset);
if (!success) {
return res.status(429).json({
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: `Rate limit exceeded. Try again at ${new Date(reset).toISOString()}`,
}
});
}
next();
}
Rate Limit Tiers
| Tier | Requests/Minute | Requests/Day | Use Case |
|---|---|---|---|
| Free | 60 | 1,000 | Hobbyists, testing |
| Pro | 600 | 50,000 | Small apps |
| Business | 6,000 | 500,000 | Production apps |
| Enterprise | Custom | Custom | High-volume |
Response Envelope Pattern
I’ve gone back and forth on response envelopes. Here’s where I’ve landed:
// For single resources - no envelope needed
GET /api/users/123
{
"id": "123",
"name": "Michael",
"email": "[email protected]"
}
// For collections - envelope with metadata
GET /api/users?page=1&limit=20
{
"data": [
{ "id": "123", "name": "Michael", ... },
{ "id": "124", "name": "Sarah", ... }
],
"pagination": {
"page": 1,
"limit": 20,
"total": 342
}
}
Pro Tip: Avoid wrapping single resources in
{ "data": { ... } }. It’s unnecessary nesting. But DO wrap collections because you need somewhere to put pagination metadata.
Filtering, Sorting, and Searching
Filtering
GET /api/orders?status=shipped&created_after=2025-01-01
GET /api/products?category=electronics&price_min=100&price_max=500
Sorting
GET /api/users?sort=created_at:desc
GET /api/products?sort=price:asc,rating:desc
Full-Text Search
GET /api/products?q=mechanical+keyboard&category=electronics
Implementation Example
// Type-safe query parsing with Zod
const ListUsersSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
sort: z.enum(['created_at:asc', 'created_at:desc', 'name:asc', 'name:desc']).default('created_at:desc'),
status: z.enum(['active', 'inactive', 'suspended']).optional(),
q: z.string().min(2).max(100).optional(),
});
app.get('/api/users', async (req, res) => {
const query = ListUsersSchema.parse(req.query);
const users = await userService.list(query);
res.json(users);
});
Common Mistakes I’ve Made (So You Don’t Have To)
1. Not Treating 404 vs Empty Array Differently
// ❌ Bad: Returns 404 when a collection has no items
GET /api/users?role=unicorn → 404
// ✅ Good: Returns empty array for collections, 404 for missing single resources
GET /api/users?role=unicorn → 200 { "data": [], "pagination": {...} }
GET /api/users/999 → 404 { "error": { "code": "RESOURCE_NOT_FOUND" } }
2. Exposing Internal IDs and Structure
// ❌ Bad: Leaks database structure
{ "id": 47, "user_id": 12, "_sequelizeTimestamp": "..." }
// ✅ Good: Clean public interface
{ "id": "usr_abc123", "createdAt": "2025-01-15T10:30:00Z" }
3. Inconsistent Date Formats
Pick ISO 8601 and use it everywhere. Always UTC.
// ✅ Always ISO 8601 in UTC
{ "createdAt": "2025-07-10T14:30:00Z" }
// ❌ Never this chaos
{ "created": "07/10/2025", "updated_at": 1720612200 }
4. Not Using HATEOAS (or at Least Links)
// Adding relevant links helps API consumers navigate
{
"id": "ord_123",
"status": "shipped",
"_links": {
"self": "/api/orders/ord_123",
"customer": "/api/users/usr_456",
"tracking": "/api/shipments/shp_789"
}
}
5. Ignoring Idempotency
For any non-idempotent operation (POST), support idempotency keys:
// Client sends:
POST /api/payments
Idempotency-Key: unique-client-generated-id
{ "amount": 5000, "currency": "usd" }
// Server stores the response and returns it for duplicate requests
// This prevents double-charging on network retries
API Documentation
Good docs are non-negotiable. I use OpenAPI (Swagger) generated from code:
// Using Zod + zod-to-openapi for automatic docs
import { createDocument } from 'zod-to-openapi';
const UserSchema = z.object({
id: z.string().openapi({ example: 'usr_abc123' }),
name: z.string().openapi({ example: 'Michael Chen' }),
email: z.string().email().openapi({ example: '[email protected]' }),
role: z.enum(['admin', 'user', 'viewer']),
createdAt: z.string().datetime(),
});
If you’re working with TypeScript 5.5’s new features, the type inference improvements make schema-first API design even more powerful. And for teams adopting GitHub Actions, you can auto-generate and publish docs on every PR merge.
FAQ
Should I use REST or GraphQL for my new API?
Start with REST unless you have a specific reason for GraphQL. REST is simpler to cache, easier to monitor, and most teams are already familiar with it. GraphQL shines when you have mobile clients that need to minimize bandwidth, or when your frontend needs wildly different data shapes from the same backend. In my experience, 80% of applications are better served by a well-designed REST API.
How do I handle breaking changes without versioning?
Use additive-only changes: add new fields (never remove), add new endpoints, add optional parameters. When you must make a breaking change, give consumers a deprecation window. Add a Sunset header to deprecated endpoints: Sunset: Sat, 01 Jan 2026 00:00:00 GMT. I typically give 6-12 months notice for breaking changes.
What’s the best way to handle file uploads in a REST API?
Use multipart/form-data for direct uploads under 10MB. For larger files, use pre-signed URLs (upload directly to S3/GCS from the client). This keeps your API servers stateless and avoids memory issues. Always validate file types server-side — never trust the Content-Type header from the client.
Should I use UUIDs or auto-increment IDs?
Use UUIDs (or ULIDs for sortable ones) for public-facing IDs. Auto-increment integers leak information (total count, creation order) and cause conflicts in distributed systems. I use ULIDs for everything now — they’re sortable, globally unique, and URL-safe. Format them with a prefix for readability: usr_01H2XKBVP6..., ord_01H2XKCQM9....
How do I design APIs for real-time features?
For most real-time needs, Server-Sent Events (SSE) are simpler than WebSockets and work through standard HTTP infrastructure. Use WebSockets only when you need bidirectional communication (chat, collaborative editing). For webhooks, implement a retry strategy with exponential backoff and dead-letter queues for failed deliveries.
