DEV Community

REST API Design: 10 Rules I Wish Someone Told Me Earlier

I've built probably 20+ REST APIs at this point. The first few were terrible. Like "return everything as a 200 with an error message in the body" terrible.

Over time, through a lot of trial and error (mostly error), I've collected a set of rules that make APIs actually pleasant to work with. I'm Daniil, 19, and these are the 10 REST API design rules I wish someone had just told me from the start.

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

Always plural. /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.

Here are the ones you'll use 90% of the time:

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

You will change your API. It's not a question of if, it's when. If you don't version it, you'll break every client that uses it.

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

Never return an unbounded list. I learned this the hard way when an endpoint that "only had a few hundred records" suddenly had 50,000 and the response was 12MB.

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.

# In your controller
page = max(1, int(request.args.get('page', 1)))
per_page = min(100, int(request.args.get('per_page', 20)))
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.

Always set these headers:

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.

Also: always use ISO 8601 for dates (2026-03-01T12:00:00Z). Not unix timestamps, not 03/01/2026, not some custom format. ISO 8601.

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. Plural nouns always (/users not /user)
  3. Proper status codes (stop returning 200 for errors)
  4. Version from day one (/v1/users)
  5. Paginate everything with query params
  6. Consistent error format across all endpoints
  7. Nest resources but max 2 levels
  8. JSON everything with consistent key naming
  9. Rate limit and tell clients about it in headers
  10. Document like your users' sanity matters

Follow these 10 rules and your API will be better than 90% of what's out there. Seriously.


If you found this useful, I share more stuff like this on Telegram and sell developer toolkits on Boosty.

Top comments (0)