Advertisement

REST API Design: Best Practices and Common Pitfalls

October 24, 202528 min readAPI Design

Designing a great REST API is both an art and a science. A well-designed API is intuitive, consistent, and scalable. This comprehensive guide covers everything from basic principles to advanced patterns for building production-ready RESTful APIs.

What is REST?

REST (Representational State Transfer) is an architectural style for designing networked applications. It relies on stateless, client-server communication using standard HTTP methods and follows a set of constraints that make APIs predictable and maintainable.

REST Principles

  1. Client-Server: Separation of concerns between UI and data storage
  2. Stateless: Each request contains all information needed to process it
  3. Cacheable: Responses explicitly indicate if they can be cached
  4. Uniform Interface: Consistent way to interact with resources
  5. Layered System: Client doesn't know if connected directly to server or intermediary
  6. Code on Demand (Optional): Server can extend client functionality

HTTP Methods

REST APIs use standard HTTP methods to perform CRUD operations:

MethodPurposeIdempotentSafe
GETRetrieve resource(s)✅ Yes✅ Yes
POSTCreate new resource❌ No❌ No
PUTUpdate/replace entire resource✅ Yes❌ No
PATCHPartial update of resource❌ No❌ No
DELETERemove resource✅ Yes❌ No

Resource Naming Conventions

Use Nouns, Not Verbs

❌ Bad:

GET /getUsers
POST /createUser
PUT /updateUser/123
DELETE /deleteUser/123

✅ Good:

GET /users
POST /users
PUT /users/123
DELETE /users/123

Use Plural Nouns for Collections

❌ Bad:

GET /user
GET /user/123
POST /user

✅ Good:

GET /users
GET /users/123
POST /users

Nested Resources

// Get all posts by a user
GET /users/123/posts

// Get a specific post by a user
GET /users/123/posts/456

// Create a new post for a user
POST /users/123/posts

// Get comments on a post
GET /users/123/posts/456/comments

// Avoid deep nesting (max 2-3 levels)
❌ GET /users/123/posts/456/comments/789/replies/101

Use Lowercase and Hyphens

❌ Bad:

GET /userProfiles
GET /User_Profiles
GET /USERS

✅ Good:

GET /user-profiles
GET /users
GET /blog-posts

HTTP Status Codes

Always use appropriate HTTP status codes to indicate the result of an operation:

Success Codes (2xx)

  • 200 OK - Successful GET, PUT, PATCH, or DELETE
  • 201 Created - Successful POST that creates a resource
  • 204 No Content - Successful request with no response body

Client Error Codes (4xx)

  • 400 Bad Request - Invalid request syntax or parameters
  • 401 Unauthorized - Authentication required or failed
  • 403 Forbidden - Authenticated but not authorized
  • 404 Not Found - Resource doesn't exist
  • 409 Conflict - Request conflicts with current state
  • 422 Unprocessable Entity - Validation errors
  • 429 Too Many Requests - Rate limit exceeded

Server Error Codes (5xx)

  • 500 Internal Server Error - Generic server error
  • 502 Bad Gateway - Invalid response from upstream server
  • 503 Service Unavailable - Server temporarily unavailable
  • 504 Gateway Timeout - Upstream server timeout

Error Handling

Consistent Error Response Format

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request parameters",
    "details": [
      {
        "field": "email",
        "message": "Email format is invalid"
      },
      {
        "field": "age",
        "message": "Age must be between 18 and 120"
      }
    ],
    "timestamp": "2025-10-24T10:30:00Z",
    "path": "/api/users",
    "requestId": "req-123-456"
  }
}

Implementation Example (Express.js)

// Error handler middleware
app.use((err, req, res, next) => {
  const error = {
    code: err.code || 'INTERNAL_ERROR',
    message: err.message || 'An unexpected error occurred',
    timestamp: new Date().toISOString(),
    path: req.path,
    requestId: req.id
  };

  // Add validation details if present
  if (err.validationErrors) {
    error.details = err.validationErrors.map(e => ({
      field: e.field,
      message: e.message
    }));
  }

  // Log error (but don't expose internal details to client)
  console.error('API Error:', {
    ...error,
    stack: err.stack
  });

  // Send appropriate status code
  const statusCode = err.statusCode || 500;

  res.status(statusCode).json({ error });
});

Filtering, Sorting, and Pagination

Filtering

// Filter by single field
GET /users?status=active

// Filter by multiple fields
GET /users?status=active&role=admin

// Filter with comparison operators
GET /products?price_min=10&price_max=100

// Filter by date range
GET /orders?created_after=2025-01-01&created_before=2025-12-31

Sorting

// Sort by single field (ascending)
GET /users?sort=name

// Sort descending (use minus prefix)
GET /users?sort=-created_at

// Sort by multiple fields
GET /users?sort=last_name,first_name

// Sort with explicit direction
GET /users?sort=name:asc,created_at:desc

Pagination

// Offset-based pagination
GET /users?page=2&limit=20

// Cursor-based pagination (better for large datasets)
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20

// Response format
{
  "data": [...],
  "pagination": {
    "total": 1000,
    "page": 2,
    "limit": 20,
    "totalPages": 50,
    "hasNext": true,
    "hasPrev": true,
    "nextCursor": "eyJpZCI6MTQzfQ",
    "prevCursor": "eyJpZCI6MTAzfQ"
  },
  "links": {
    "self": "/users?page=2&limit=20",
    "first": "/users?page=1&limit=20",
    "prev": "/users?page=1&limit=20",
    "next": "/users?page=3&limit=20",
    "last": "/users?page=50&limit=20"
  }
}

Versioning Strategies

1. URL Path Versioning (Most Common)

GET /api/v1/users
GET /api/v2/users

// Pros: Clear, easy to implement, cache-friendly
// Cons: Multiple endpoints to maintain

2. Header Versioning

GET /api/users
Accept: application/vnd.myapi.v2+json

// Pros: Clean URLs
// Cons: Less visible, harder to test

3. Query Parameter Versioning

GET /api/users?version=2

// Pros: Easy to implement
// Cons: Can be overlooked, pollutes query parameters

Authentication & Authorization

Bearer Token Authentication

// Request
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

// Implementation
const authMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: {
        code: 'UNAUTHORIZED',
        message: 'Authentication required'
      }
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({
      error: {
        code: 'INVALID_TOKEN',
        message: 'Token is invalid or expired'
      }
    });
  }
};

Rate Limiting

// Rate limit headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1635724800

// When rate limit exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1635724800
Retry-After: 3600

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please try again later.",
    "retryAfter": 3600
  }
}

// Implementation with express-rate-limit
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: {
    error: {
      code: 'RATE_LIMIT_EXCEEDED',
      message: 'Too many requests from this IP'
    }
  },
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/api/', limiter);

HATEOAS (Hypermedia)

Include links to related resources in responses:

{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com",
  "_links": {
    "self": {
      "href": "/users/123"
    },
    "posts": {
      "href": "/users/123/posts"
    },
    "followers": {
      "href": "/users/123/followers"
    },
    "following": {
      "href": "/users/123/following"
    }
  }
}

Caching

Cache-Control Headers

// Cache for 1 hour
Cache-Control: public, max-age=3600

// Don't cache (for sensitive data)
Cache-Control: no-store, no-cache, must-revalidate

// Cache but revalidate
Cache-Control: public, max-age=0, must-revalidate

// Implementation
app.get('/api/users/:id', (req, res) => {
  const user = getUserById(req.params.id);

  // Set cache headers
  res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
  res.set('ETag', generateETag(user));

  res.json(user);
});

ETags for Conditional Requests

// Client sends ETag from previous request
GET /api/users/123
If-None-Match: "33a64df551425fcc55e4d42a148795d9"

// Server responds with 304 if unchanged
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9"

// Or 200 with new data if changed
HTTP/1.1 200 OK
ETag: "686897696a7c876b7e"
{ ... new data ... }

Security Best Practices

  • ✅ Always use HTTPS in production
  • ✅ Implement authentication and authorization
  • ✅ Validate all input data
  • ✅ Use rate limiting to prevent abuse
  • ✅ Implement CORS properly
  • ✅ Don't expose sensitive data in responses
  • ✅ Use security headers (HSTS, CSP, etc.)
  • ✅ Log security events and monitor for anomalies
  • ❌ Never include passwords or secrets in responses
  • ❌ Don't trust client-side validation alone

Documentation

OpenAPI/Swagger Documentation

openapi: 3.0.0
info:
  title: User API
  version: 1.0.0
  description: API for managing users

paths:
  /users:
    get:
      summary: List all users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
      responses:
        200:
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  pagination:
                    $ref: '#/components/schemas/Pagination'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email

Conclusion

Building a great REST API requires careful consideration of design patterns, naming conventions, error handling, and security. By following these best practices, you'll create APIs that are intuitive, maintainable, and scalable. Remember that consistency is key - establish patterns early and stick to them throughout your API.

Start small, iterate based on feedback, and always prioritize developer experience. A well-designed API is a pleasure to use and will accelerate development for everyone who integrates with it.

Test Your APIs Instantly

Use our free API tester to quickly test REST endpoints with custom headers and authentication!

Try API Tester →
Advertisement