API Design That Ages Well: Versioning, Pagination, and Errors

The API design decisions you can't easily undo — URL structure, pagination that survives large datasets, consistent errors, and versioning strategies that don't break every client.

Backend & Data: API Design That Ages Well: Versioning, Pagination, and Errors

Why API design is different

Regular code, you can refactor freely — it’s all internal. An API is a contract with people you can’t call. Once clients depend on it, every change risks breaking someone’s integration, and you often can’t even tell who’s using what. That asymmetry is why API design deserves more upfront thought than most code: the cost of getting it wrong is paid later, repeatedly, by everyone downstream.

This isn’t about REST-vs-GraphQL dogma. It’s the decisions that are painful to reverse once you have real clients — regardless of style.

Resource naming: nouns, plural, consistent

URLs are the most visible part of your API and the hardest to change later. A few conventions that have earned their place:

GET    /users          # list users
POST   /users          # create a user
GET    /users/42       # get one user
PATCH  /users/42       # update part of a user
DELETE /users/42       # delete a user
GET    /users/42/posts # that user's posts (nesting for real ownership)

The principles:

  • Nouns, not verbs. /users, not /getUsers. The HTTP method is the verb — GET, POST, DELETE already say the action.
  • Plural, consistently. Pick /users (not /user) and never mix. Consistency matters more than which you pick.
  • Nest for genuine ownership, but don’t go deep. /users/42/posts is fine; /users/42/posts/7/comments/3/likes is a nightmare — past one level, prefer /comments/3/likes.

Verbs creep in for actions that aren’t CRUD (“publish this post”, “cancel this order”). Pragmatism wins there — POST /orders/42/cancel is clearer than contorting it into pure REST. Don’t be dogmatic; be consistent and clear.

Use HTTP status codes honestly

The status code is the first thing a client checks. Use the real ones for what they mean:

200 OK              # success
201 Created         # POST created a resource
204 No Content      # success, nothing to return (e.g. DELETE)
400 Bad Request     # client sent something invalid
401 Unauthorized    # not authenticated (you don't know who they are)
403 Forbidden       # authenticated but not allowed (you know, and no)
404 Not Found       # resource doesn't exist
409 Conflict        # duplicate, version conflict
422 Unprocessable   # validation failed
429 Too Many Requests  # rate limited
500 Internal Error  # your fault, not theirs

The anti-pattern that makes clients miserable: returning 200 OK with {"error": "..."} in the body. Now every client has to parse the body to know if it worked, and generic HTTP tooling (retries, monitoring, caches) can’t tell success from failure. Let the status code carry the truth. The 401 vs 403 distinction also trips people: 401 means “I don’t know who you are,” 403 means “I know exactly who you are, and no.”

Errors clients can actually handle

When something goes wrong, a bare string helps nobody. Return structured, consistent errors — the same shape every time:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request could not be processed",
    "details": [
      { "field": "email", "issue": "must be a valid email address" },
      { "field": "age", "issue": "must be a positive integer" }
    ]
  }
}
  • A stable code the client can branch on programmatically (VALIDATION_FAILED), separate from the human message (which you’re free to reword).
  • details so a form can show errors on the right fields, instead of one vague banner.
  • The same envelope everywhere — clients write error handling once, not per endpoint.

Nothing frustrates an API consumer more than every endpoint failing in a slightly different shape. Pick one error format on day one and hold the line.

Pagination: the decision that bites at scale

GET /users is fine with 50 users. With 5 million, returning them all melts your server and the client. You must paginate — and which pagination you pick is hard to change later, so choose deliberately.

Offset pagination — simple, and quietly broken at scale

GET /users?limit=20&offset=40    # skip 40, take 20

Easy to build, allows jumping to any page, and everyone reaches for it first. Two real problems:

  1. It gets slow on big tables. OFFSET 1000000 makes the database count through a million rows to discard them. Deep pages crawl.
  2. It skips and duplicates rows when data changes. If a new user is inserted while someone pages through, offset shifts everything down — page 2 now repeats a row from page 1, or misses one. On a live, changing dataset this silently corrupts what the client sees.

Fine for small or static data. A trap for large or actively-changing data.

Cursor pagination — scales, at a cost

GET /users?limit=20
→ { "data": [...], "nextCursor": "eyJpZCI6NjB9" }

GET /users?limit=20&cursor=eyJpZCI6NjB9    # continue after that point

Instead of “skip N”, the cursor says “give me rows after this specific point” (usually an indexed id or timestamp):

-- No counting through skipped rows — uses the index directly
SELECT * FROM users WHERE id > $cursor ORDER BY id LIMIT 20;

It stays fast no matter how deep you go, and it doesn’t skip or duplicate when data changes underneath. The tradeoffs: no jumping to “page 47” (only next/previous), and the cursor is opaque to clients. For feeds, infinite scroll, and any large or live dataset, it’s the right call — and switching to it after clients depend on offset is painful, which is exactly why it’s worth deciding early.

Versioning: changing without breaking everyone

Your API will need to change in ways that break existing clients. Versioning lets old clients keep working while new ones get the new shape.

URL versioning — most common, most visible:

/v1/users
/v2/users

Header versioning — cleaner URLs, less obvious:

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

URL versioning wins for most APIs purely because it’s visible and debuggable — you can paste it in a browser, see it in logs, and everyone immediately understands it. Header versioning is more “correct” by REST purism and keeps URLs clean, but it’s easier to get wrong and harder to inspect.

The deeper skill is minimizing breaking changes so you version rarely:

  • Adding a field is non-breaking — clients ignore what they don’t know. Add freely.
  • Removing or renaming a field is breaking. Avoid it; if you must, add the new alongside the old and deprecate the old on a timeline.
  • Changing a type (string → object) is breaking. Add a new field instead.

A version bump is expensive — you maintain two versions and chase clients to migrate. Design additively and you’ll bump versions far less often. The best versioning strategy is needing to version rarely.

A few more that pay off

Return the created/updated resource. After a POST or PATCH, send back the full object (with server-generated fields like id and createdAt) so the client doesn’t need a second request to see what it just made.

Be consistent with field naming. Pick camelCase or snake_case and never mix. Clients build models off your field names; inconsistency means bugs and irritation.

Support filtering, sorting, and field selection on list endpoints as they grow:

GET /users?status=active&sort=-createdAt&fields=id,name,email

-createdAt for descending, fields= to let clients fetch only what they need (less bandwidth, faster). Add these when real usage calls for them, not speculatively.

Rate limit, and tell clients about it. Return the limits in headers so well-behaved clients can back off before they hit the wall:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 12
X-RateLimit-Reset: 1694560000

Document as you build

An undocumented API is one people can’t adopt without reverse-engineering it. Write an OpenAPI (Swagger) spec alongside the code — it generates interactive docs, client libraries, and request validation from a single source of truth. Documentation written the same week as the endpoint is accurate; documentation promised for “later” never comes, and the gap becomes support tickets.

The throughline

Good API design is mostly about the decisions you can’t easily walk back: URL structure, pagination strategy, error shape, versioning approach. Get those right early — resource-based URLs, honest status codes, one consistent error envelope, cursor pagination where data is large or live, and an additive-first approach that lets you version rarely — and your API can grow for years without breaking the people who depend on it. Get them wrong, and you’re either shipping breaking changes constantly or living with the mistakes because too many clients now rely on them. The upfront thought is cheap; the reversal is not.