March 26, 20266 min read

REST API Design: How to Not Make Everyone Hate Your API

Practical guide to designing REST APIs that don't suck. Resource naming, HTTP methods, status codes, pagination, error formats, and the anti-patterns you see everywhere.

rest-api api-design backend http web-development
Ad 336x280

You can tell a lot about a backend team by their API design. Good APIs are predictable -- you can guess the endpoint for something you haven't used yet. Bad APIs make you check the docs for every single request because nothing is consistent. Here's how to design APIs that developers actually enjoy using.

Resources, Not Actions

REST is about resources (nouns), not actions (verbs). The HTTP method IS the verb.

# Good
GET    /users          -- list users
GET    /users/42       -- get user 42
POST   /users          -- create a user
PUT    /users/42       -- replace user 42
PATCH  /users/42       -- partially update user 42
DELETE /users/42       -- delete user 42

# Bad
GET    /getUsers
POST   /createUser
POST   /deleteUser/42
GET    /getUserById?id=42

If you have verbs in your URLs, something's wrong. The exception is truly action-oriented operations that don't map to CRUD (like /users/42/deactivate), but even those can often be modeled as state changes: PATCH /users/42 with {"status": "inactive"}.

Nested Resources

Use nesting to express relationships:

GET  /users/42/posts        -- posts by user 42
GET  /users/42/posts/7      -- post 7 by user 42
POST /users/42/posts        -- create a post for user 42

Don't go deeper than two levels. /users/42/posts/7/comments/3/replies/1 is a nightmare. Flatten it: /replies/1 or /comments/3/replies.

Pluralize Everything

/users      not  /user
/posts      not  /post
/categories not  /category

Pick plural and be consistent. Having /user/42 but /posts will annoy everyone.

HTTP Methods: Use Them Correctly

MethodPurposeIdempotent?Safe?
GETReadYesYes
POSTCreateNoNo
PUTFull replaceYesNo
PATCHPartial updateYes*No
DELETERemoveYesNo
Idempotent means calling it twice has the same effect as calling it once. DELETE /users/42 twice? User is still deleted. POST /users twice? You might get two users. This distinction matters for retry logic. PUT vs PATCH: PUT replaces the entire resource (you send all fields). PATCH sends only the fields you want to change. Most of the time, PATCH is what you actually want.

Status Codes That Matter

Stop returning 200 for everything. Status codes exist for a reason, and clients depend on them.

Success codes:
  • 200 OK -- general success, response has a body
  • 201 Created -- resource was created (return the created resource, plus a Location header)
  • 204 No Content -- success, but nothing to return (good for DELETE)
Client error codes:
  • 400 Bad Request -- malformed request, validation error, missing required field
  • 401 Unauthorized -- not authenticated (should really be called "Unauthenticated")
  • 403 Forbidden -- authenticated but not allowed
  • 404 Not Found -- resource doesn't exist
  • 409 Conflict -- state conflict (duplicate email, version mismatch)
  • 422 Unprocessable Entity -- request is well-formed but semantically invalid
Server error codes:
  • 500 Internal Server Error -- something broke on your end
  • 503 Service Unavailable -- temporarily down (include Retry-After header)
A special callout: don't return 200 with an error message in the body. This breaks every HTTP client's error handling. If it's an error, use an error status code.

Error Response Format

Pick a format and use it everywhere:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address"
      },
      {
        "field": "age",
        "message": "Must be a positive integer"
      }
    ]
  }
}

Key points:


  • Machine-readable error code (VALIDATION_ERROR, not just a string message)

  • Human-readable message for debugging

  • Field-level details for validation errors (so the frontend can show errors next to the right form field)

  • Same structure for every error endpoint. No guessing.


Pagination

Any endpoint that returns a list needs pagination. Returning 50,000 records because someone forgot to add ?limit=20 will take down your server.

Offset-based (simple, most common):
GET /posts?page=2&per_page=20

{
"data": [...],
"pagination": {
"page": 2,
"per_page": 20,
"total": 143,
"total_pages": 8
}
}

Cursor-based (better for real-time data):
GET /posts?after=eyJpZCI6MTAwfQ&limit=20

{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}
}

Cursor-based avoids the "page drift" problem where items shift between pages as new content is added. Use it for feeds, timelines, or anything where data changes frequently.

Filtering and Sorting

Keep it simple and consistent:

GET /posts?status=published&author_id=42&sort=-created_at
  • Filter by field: field=value
  • Sort: sort=field (ascending) or sort=-field (descending)
  • Multiple sort fields: sort=-created_at,title
For more complex filtering, some APIs use operators:
GET /products?price[gte]=10&price[lte]=50
GET /products?created_at[after]=2026-01-01

Don't invent your own query language. If you need complex queries, consider GraphQL instead.

Common Anti-Patterns

Inconsistent naming. /users uses camelCase (firstName) but /products uses snake_case (product_name). Pick one. snake_case is the most common convention in JSON APIs. Version in the URL path. /v1/users is fine. /api/v2/users is fine. Just don't mix versioned and unversioned endpoints, and don't break v1 when you ship v2. Returning different shapes for the same resource. If GET /users/42 returns {id, name, email} but GET /users returns [{userId, fullName, emailAddress}], that's a bug. Same resource, same field names, always. Ignoring HTTP caching. Set Cache-Control headers on GET responses. Use ETag or Last-Modified for conditional requests. Your API might be fast, but HTTP caching makes it faster and reduces load. Not including the created resource in POST responses. When someone creates a resource, return it. They need the server-generated ID, timestamps, and any defaults you applied. Don't make them do a separate GET request immediately after creating something.

Getting Started

The best way to internalize good API design is to build some APIs yourself. If you're learning backend development, CodeUp gives you a hands-on environment to practice building and consuming APIs. Once you've felt the pain of a badly designed API from the consumer side, you'll never build one yourself.

Good API design isn't about following rules for their own sake. It's about making your API predictable enough that developers can use it without constantly referring to the docs. Consistency beats cleverness every time.

Ad 728x90