DEV Community

Alex Chen
Alex Chen

Posted on

REST API Design: Building APIs That Developers Love (2026)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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)