Programming

REST API Design Best Practices: The Complete Guide for 2026

2026-07-05·11 min read
#REST API#API design#backend#architecture

A well-designed REST API is a joy to work with. A poorly designed one is a nightmare that costs your team hours every week. The difference isn't about technology — it's about following conventions that make your API intuitive, consistent, and predictable.

This guide covers the rules that every API designer should follow, with examples of what to do and what to avoid.

URL Structure

Use Nouns, Not Verbs

# BAD: Verbs in URLs
POST /createUser
GET /getUserById/123
POST /updateUser/123
GET /deleteUser/123

# GOOD: Nouns + HTTP methods
POST /users           # Create
GET /users/123        # Read
PUT /users/123        # Update (full)
PATCH /users/123      # Update (partial)
DELETE /users/123     # Delete

The HTTP method is the verb. The URL identifies the resource.

Use Plural Nouns

# BAD
GET /user/123
GET /user

# GOOD
GET /users/123
GET /users

Consistency: always plural. /users, /orders, /products, /posts.

Nested Resources for Relationships

# User's posts
GET /users/123/posts

# Specific post by a user
GET /users/123/posts/456

# Post's comments
GET /posts/456/comments

# But DON'T nest too deep
# BAD: /users/123/posts/456/comments/789/replies/012
# GOOD: /comments/789/replies (flatten after one level)

Rule of thumb: Maximum one level of nesting. /users/123/posts is fine. /users/123/posts/456/comments/789 is not.

Use Query Parameters for Filtering, Sorting, Pagination

# Filter
GET /products?category=electronics&brand=apple
GET /products?price_min=100&price_max=500

# Sort
GET /products?sort=-created_at          # Descending by created_at
GET /products?sort=name                 # Ascending by name
GET /products?sort=-price,name          # Multi-sort

# Paginate
GET /products?page=1&limit=20
GET /products?cursor=abc123&limit=20    # Cursor-based (better for large datasets)

# Select fields
GET /products?fields=id,name,price

# Search
GET /products?q=wireless+headphones

# Expand related resources
GET /orders/123?include=user,products

HTTP Methods

| Method | Purpose | Safe | Idempotent | |--------|---------|------|------------| | GET | Read resource | Yes | Yes | | POST | Create resource | No | No | | PUT | Replace resource (full) | No | Yes | | PATCH | Modify resource (partial) | No | No* | | DELETE | Remove resource | No | Yes | | HEAD | Check existence | Yes | Yes | | OPTIONS | Available methods | Yes | Yes |

  • Safe: Doesn't modify data
  • Idempotent: Repeating gives same result
  • *PATCH is technically not idempotent, but should be designed to be

POST vs PUT vs PATCH

# POST: Create new resource (server assigns ID)
POST /users
Body: { "name": "John", "email": "john@example.com" }
Response: 201 Created, { "id": 123, "name": "John", ... }

# PUT: Replace entire resource (client provides all fields)
PUT /users/123
Body: { "name": "John", "email": "john@newemail.com", "age": 31 }
Response: 200 OK, { "id": 123, ... }

# PATCH: Partial update (only changed fields)
PATCH /users/123
Body: { "email": "john@newemail.com" }
Response: 200 OK, { "id": 123, "email": "john@newemail.com", ... }

HTTP Status Codes

2xx Success

| Code | Meaning | When to Use | |------|---------|-------------| | 200 OK | Success | GET, PUT, PATCH, DELETE success | | 201 Created | Resource created | POST that creates new resource | | 202 Accepted | Request queued | Long-running operations | | 204 No Content | Success, no body | DELETE success |

3xx Redirection

| Code | Meaning | When to Use | |------|---------|-------------| | 301 Moved Permanently | URL changed | Resource moved to new URL | | 304 Not Modified | Use cache | Conditional GET with ETag |

4xx Client Errors

| Code | Meaning | When to Use | |------|---------|-------------| | 400 Bad Request | Invalid input | Malformed JSON, validation errors | | 401 Unauthorized | Not authenticated | Missing/invalid token | | 403 Forbidden | Not authorized | Valid auth but no permission | | 404 Not Found | Resource doesn't exist | Invalid ID | | 409 Conflict | Duplicate resource | Email already exists | | 422 Unprocessable Entity | Validation failed | Valid JSON but business rule violation | | 429 Too Many Requests | Rate limited | Exceeded API limits |

5xx Server Errors

| Code | Meaning | When to Use | |------|---------|-------------| | 500 Internal Server Error | Unhandled error | Unexpected server error | | 502 Bad Gateway | Upstream failed | Database down | | 503 Service Unavailable | Server overloaded | Maintenance mode | | 504 Gateway Timeout | Upstream timeout | External API timeout |

The Big Mistake: Using 200 for Errors

// BAD: HTTP 200 with error body
HTTP 200 OK
{
  "success": false,
  "error": "User not found"
}

// GOOD: Proper HTTP status codes
HTTP 404 Not Found
{
  "error": "User not found",
  "code": "USER_NOT_FOUND"
}

HTTP status codes exist for a reason. Use them correctly.

Response Format

Consistent Envelope

// Single resource
{
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"
  }
}

// Collection
{
  "data": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ],
  "meta": {
    "total": 100,
    "page": 1,
    "limit": 20,
    "total_pages": 5
  }
}

// Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      { "field": "email", "message": "Email is required" }
    ]
  }
}

Use ISO 8601 Dates

// GOOD
{
  "created_at": "2026-07-05T14:30:00Z",
  "updated_at": "2026-07-05T15:45:00+08:00"
}

// BAD
{
  "created_at": "07/05/2026",
  "updated_at": 1751723400
}

ISO 8601 is unambiguous, machine-readable, and supports timezones.

Lowercase Field Names with Underscores

// Pick one and be consistent
{
  "first_name": "John",      // snake_case (Python/Ruby convention)
  "created_at": "2026-..."
}

// Or
{
  "firstName": "John",       // camelCase (JavaScript convention)
  "createdAt": "2026-..."
}

The important thing is consistency. Don't mix firstName and last_name.

Pagination

Page-Based (Simple)

GET /products?page=3&limit=20
{
  "data": [...],
  "meta": {
    "page": 3,
    "limit": 20,
    "total": 500,
    "total_pages": 25
  }
}

Cursor-Based (Scalable)

GET /products?cursor=eyJpZCI6MTAwfQ&limit=20
{
  "data": [...],
  "meta": {
    "limit": 20,
    "next_cursor": "eyJpZCI6MTIwfQ",
    "has_more": true
  }
}

Use cursor-based for large datasets. Page-based gets slow on page 10,000 because the database must skip 200,000 rows. Cursor-based uses a WHERE clause that's always fast.

Authentication

Bearer Token (JWT)

# Client sends token in Authorization header
GET /users/123
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# Server validates token and identifies user

API Keys (for Service-to-Service)

GET /api/v1/users
X-API-Key: your-api-key-here

OAuth 2.0 (for Third-Party Access)

# Authorization code flow
GET /auth/callback?code=abc123
# Exchange code for access token
POST /auth/token
  grant_type=authorization_code
  code=abc123
  client_id=...
  client_secret=...

Versioning

URL Versioning (Most Common)

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

Simple, explicit, and easy to route. The downside: URL changes between versions.

Header Versioning

GET /users
Accept: application/vnd.api+json;version=2

Cleaner URLs but harder to test in a browser.

When to Version

Breaking changes require a new version:

  • Removing a field
  • Changing a field type
  • Changing URL structure
  • Changing authentication mechanism

Non-breaking changes DON'T require versioning:

  • Adding new fields
  • Adding new endpoints
  • Adding optional parameters

Rate Limiting

Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 942
X-RateLimit-Reset: 1720000000

When Rate Limited

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Retry after 60 seconds."
  }
}

CORS (Cross-Origin Resource Sharing)

# Required headers for browser-accessible APIs
Access-Control-Allow-Origin: https://yourapp.com   # Specific origin (not *)
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
Access-Control-Allow-Credentials: true

Handle preflight OPTIONS requests:

OPTIONS /users
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type

# Response
HTTP/1.1 204 No Content
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

Error Handling Best Practices

Structured Error Responses

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format"
      },
      {
        "field": "password",
        "message": "Password must be at least 12 characters"
      }
    ],
    "documentation_url": "https://docs.example.com/api/errors/validation"
  }
}

Use Meaningful Error Codes

// Don't do this
{ "error": "Something went wrong" }

// Do this
{
  "error": {
    "code": "INSUFFICIENT_STOCK",
    "message": "Cannot add 5 units of 'Wireless Headphones' to cart. Only 3 available.",
    "details": {
      "product_id": 42,
      "requested": 5,
      "available": 3
    }
  }
}

API Documentation

OpenAPI/Swagger

Use OpenAPI 3.1 to document your API:

openapi: 3.1.0
info:
  title: Blog API
  version: 1.0.0

paths:
  /posts:
    get:
      summary: List all posts
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Post'

Tools like Swagger UI, Redoc, and Stoplight render this into interactive documentation.

Checklist: REST API Audit

  • [ ] URLs use plural nouns
  • [ ] HTTP methods are used correctly (GET never modifies data)
  • [ ] Appropriate HTTP status codes (not 200 for everything)
  • [ ] Consistent JSON structure across all endpoints
  • [ ] Pagination on all collection endpoints
  • [ ] Rate limiting implemented
  • [ ] Authentication required (except public endpoints)
  • [ ] CORS configured correctly
  • [ ] Input validation on all endpoints
  • [ ] Meaningful error messages with error codes
  • [ ] API versioned (v1)
  • [ ] Documentation (OpenAPI/Swagger)
  • [ ] HTTPS enforced
  • [ ] No sensitive data in URLs (use headers/body)
  • [ ] Consistent field naming (snake_case or camelCase, not both)

Conclusion

Good API design is invisible — developers use your API without thinking about it. Bad API design is constantly in the way — every endpoint has surprises, responses are inconsistent, and integration takes twice as long as expected.

The rules in this guide aren't arbitrary. They're conventions that the entire web development ecosystem follows. Following them means your API works the way developers expect, integrates smoothly with any HTTP client, and stands the test of time.

Design your API as if you were going to be the one consuming it. Because eventually, you will be.