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

  1. Adding a new field to a response → No version bump needed
  2. Adding a new optional parameter → No version bump needed
  3. Removing/renaming a field → Major version bump
  4. Changing a field’s type → Major version bump
  5. 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
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/999404 { "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 }
// 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.