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

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

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

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

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

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

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)