DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

Ten REST API design conventions, with trade-offs

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

Rule 1: Use Nouns for Endpoints, Not Verbs

The HTTP method already tells you the action. The URL should describe the resource.

Bad:

GET    /getUsers
POST   /createUser
PUT    /updateUser/123
DELETE /deleteUser/123
GET    /getAllOrdersForUser/123
Enter fullscreen mode Exit fullscreen mode

Good:

GET    /users
POST   /users
PUT    /users/123
DELETE /users/123
GET    /users/123/orders
Enter fullscreen mode Exit fullscreen mode

The verb is already in the HTTP method (GET, POST, PUT, DELETE). Putting it in the URL too is redundant. Think of URLs as addresses to resources, not function names.

Rule 2: Use Plural Nouns

This one seems small but it eliminates a whole category of confusion.

Bad (inconsistent):

GET /user       -- all users? one user? who knows
GET /user/123   -- ok this is one user
GET /users      -- wait, different endpoint for the list?
Enter fullscreen mode Exit fullscreen mode

Good (consistent):

GET /users      -- list of users
GET /users/123  -- single user
POST /users     -- create a user
Enter fullscreen mode Exit fullscreen mode

Plural resource names are a common convention, not a protocol requirement. /users returns a list. /users/123 returns one from that list. Simple and predictable.

Rule 3: Use Proper HTTP Status Codes

Do not just return 200 for everything. Status codes exist for a reason and every HTTP client knows how to handle them.

200 OK              -- Successful GET, PUT, PATCH
201 Created         -- Successful POST that creates a resource
204 No Content      -- Successful DELETE
400 Bad Request     -- Client sent invalid data
401 Unauthorized    -- No valid authentication
403 Forbidden       -- Authenticated but not allowed
404 Not Found       -- Resource doesn't exist
409 Conflict        -- Resource state conflict (duplicate email, etc.)
422 Unprocessable   -- Validation failed
429 Too Many Req    -- Rate limit exceeded
500 Internal Error  -- Something broke on the server
Enter fullscreen mode Exit fullscreen mode

Bad:

HTTP 200 OK
{
    "success": false,
    "error": "User not found"
}
Enter fullscreen mode Exit fullscreen mode

Good:

HTTP 404 Not Found
{
    "error": {
        "code": "USER_NOT_FOUND",
        "message": "No user found with id 123"
    }
}
Enter fullscreen mode Exit fullscreen mode

Your API consumers will write cleaner code because they can rely on status codes instead of parsing your response body for hidden error messages.

Rule 4: Version Your API From Day One

Plan how the API will evolve. Additive compatible changes and breaking changes need different handling; URL versioning is one option.

Common approaches:

# URL versioning (most common, my preference)
GET /v1/users
GET /v2/users

# Header versioning
GET /users
Accept: application/vnd.myapi.v1+json

# Query parameter
GET /users?version=1
Enter fullscreen mode Exit fullscreen mode

I go with URL versioning because it's the most obvious and easiest to test. You can literally see the version in the browser URL bar.

Start with /v1/ and when you need breaking changes, create /v2/. Keep /v1/ running until clients migrate.

Rule 5: Use Pagination, Filtering, and Sorting Via Query Parameters

Pagination:

GET /users?page=2&per_page=20

Response:
{
    "data": [...],
    "pagination": {
        "page": 2,
        "per_page": 20,
        "total": 1234,
        "total_pages": 62
    }
}
Enter fullscreen mode Exit fullscreen mode

Filtering:

GET /users?status=active&role=admin
GET /orders?created_after=2026-01-01&status=shipped
Enter fullscreen mode Exit fullscreen mode

Sorting:

GET /users?sort=created_at&order=desc
GET /products?sort=price&order=asc
Enter fullscreen mode Exit fullscreen mode

Always set a default page size and a maximum page size. Don't let someone request per_page=1000000.

# Framework-neutral validation result. The route must honor the returned status.
def pagination_response(args):
    try:
        page = int(args.get("page", 1))
        per_page = int(args.get("per_page", 20))
    except (TypeError, ValueError):
        return {"error": "page and per_page must be positive integers"}, 400
    if page < 1 or per_page < 1:
        return {"error": "page and per_page must be positive integers"}, 400
    return {"page": page, "per_page": min(100, per_page)}, 200
Enter fullscreen mode Exit fullscreen mode

Rule 6: Return Consistent Error Responses

Every error from your API should follow the same format. Don't make clients guess what shape the error will be.

Bad (inconsistent):

// Sometimes this
{"error": "Not found"}

// Sometimes this
{"message": "Validation failed", "details": ["Email required"]}

// Sometimes this
{"success": false, "reason": "Server error"}
Enter fullscreen mode Exit fullscreen mode

Good (always the same shape):

{
    "error": {
        "code": "VALIDATION_FAILED",
        "message": "Request validation failed",
        "details": [
            {
                "field": "email",
                "message": "Email is required"
            },
            {
                "field": "age",
                "message": "Age must be a positive number"
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Pick a format and stick to it. Include:

  • A machine-readable error code
  • A human-readable message
  • Details for validation errors (which field, what's wrong)

Rule 7: Use Proper Nesting for Related Resources

Resources that belong to other resources should be nested in the URL. But don't go more than 2 levels deep.

Good:

GET /users/123/orders          -- orders for user 123
GET /users/123/orders/456      -- specific order for user 123
POST /users/123/orders         -- create order for user 123
Enter fullscreen mode Exit fullscreen mode

Too deep (bad):

GET /users/123/orders/456/items/789/reviews/012
Enter fullscreen mode Exit fullscreen mode

If you're going more than 2 levels deep, flatten it:

GET /order-items/789/reviews
Enter fullscreen mode Exit fullscreen mode

Or use query parameters:

GET /reviews?order_item_id=789
Enter fullscreen mode Exit fullscreen mode

Deep nesting makes URLs long, hard to read, and annoying to work with in code.

Rule 8: Accept and Return JSON

In 2026, JSON is the standard for REST APIs. Unless you have a very specific reason, use JSON for both request and response bodies.

Example headers for a JSON response:

Content-Type: application/json
Accept: application/json
Enter fullscreen mode Exit fullscreen mode

Use consistent key naming. Pick one and stick with it:

// snake_case (Python/Ruby convention)
{
    "first_name": "Daniil",
    "created_at": "2026-03-01T12:00:00Z"
}

// camelCase (JavaScript convention)
{
    "firstName": "Daniil",
    "createdAt": "2026-03-01T12:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

I use snake_case because most of my backends are Python, but the convention depends on your ecosystem. Just be consistent.

Document the date representation and timezone. An ISO 8601 timestamp is a common choice; an epoch timestamp can also be valid when its unit and meaning are explicit.

Rule 9: Implement Rate Limiting

If your API is public (or even internal), you need rate limiting. Without it, one misbehaving client can take down your entire service.

Return rate limit info in headers:

HTTP 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 67
X-RateLimit-Reset: 1709312400
Enter fullscreen mode Exit fullscreen mode

When the limit is hit:

HTTP 429 Too Many Requests
Retry-After: 30

{
    "error": {
        "code": "RATE_LIMIT_EXCEEDED",
        "message": "Too many requests. Try again in 30 seconds."
    }
}
Enter fullscreen mode Exit fullscreen mode

Simple implementation with a middleware:

from functools import wraps
from time import time

rate_limits = {}

def rate_limit(max_requests=100, window=3600):
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            client_ip = request.remote_addr
            now = time()

            if client_ip not in rate_limits:
                rate_limits[client_ip] = []

            # Remove old requests outside the window
            rate_limits[client_ip] = [
                t for t in rate_limits[client_ip]
                if now - t < window
            ]

            if len(rate_limits[client_ip]) >= max_requests:
                return jsonify({
                    "error": {
                        "code": "RATE_LIMIT_EXCEEDED",
                        "message": f"Max {max_requests} requests per {window}s"
                    }
                }), 429

            rate_limits[client_ip].append(now)
            return f(*args, **kwargs)
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

For production, use Redis or a proper rate limiting service instead of an in-memory dict. But you get the idea.

Rule 10: Document Your API Like Someone's Life Depends On It

Because someone's evening definitely depends on it. Bad API docs mean frustrated developers, more support tickets, and fewer people using your API.

At minimum, document:

  • Every endpoint with its URL, method, and description
  • Request parameters (path, query, body) with types and requirements
  • Response format with example responses
  • Error codes and what they mean
  • Authentication method

Use OpenAPI/Swagger:

openapi: 3.0.0
info:
  title: My API
  version: 1.0.0

paths:
  /users:
    get:
      summary: List all users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: List of users
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
Enter fullscreen mode Exit fullscreen mode

Tools like Swagger UI can generate interactive documentation from your OpenAPI spec. Clients can test endpoints directly in the browser.

Bonus: The Golden Rule

Design your API as if you're the one who has to consume it.

Before finalizing any endpoint, try writing the client code. If it feels awkward, the API design is probably wrong.

// Does this feel natural?
const user = await api.get('/users/123');
const orders = await api.get('/users/123/orders?status=active&sort=date&order=desc');
await api.post('/users', { name: 'Daniil', email: 'dan@example.com' });
await api.delete('/users/123');
Enter fullscreen mode Exit fullscreen mode

If you can read that code and immediately understand what it does without looking at docs, you've designed a good API.

TL;DR

  1. Nouns for URLs, verbs come from HTTP methods
  2. Consistent resource naming, such as /users
  3. Proper status codes (stop returning 200 for errors)
  4. Plan compatibility and breaking changes; /v1/users is one option
  5. Bound collection responses and document pagination
  6. Consistent error format across all endpoints
  7. Keep resource relationships and routes understandable
  8. Choose and document representations with consistent key naming
  9. Rate limit and tell clients about it in headers
  10. Document like your users' sanity matters

Top comments (0)