Skip to content

Backend

Designing REST APIs That Age Well

The design choices you make in week one decide whether year two is painful. Pagination, error shapes, idempotency, versioning and the additive-change rule that keeps clients from breaking.

4 min read0 views
  • #api
  • #rest
  • #nodejs
  • #backend
  • #architecture

An API is a promise. Once something consumes it, every change is a negotiation with people who won't upgrade on your schedule.

These are the decisions that decide whether that negotiation is easy or awful — and they're nearly all made in the first week, before there's any pressure to think about them.

Resources, not remote procedures

The URL should name a thing. The method should say what you're doing to it.

✗ POST /getUserOrders
✗ POST /api/updateOrderStatus
✗ GET  /api/deleteOrder?id=42
 
✓ GET    /users/42/orders
✓ PATCH  /orders/42
✓ DELETE /orders/42

Plural nouns, consistently. /users/42/orders, not /user/42/order. It's arbitrary — but pick one and never deviate, because mixed conventions mean every consumer has to check the docs for every endpoint.

Some operations genuinely aren't CRUD. Don't contort them:

POST /orders/42/refund
POST /articles/17/publish

A sub-resource that names the action is clearer than PATCH /orders/42 { "status": "refunded" } — and it gives you a distinct place to enforce permissions and write an audit entry.

One error shape, everywhere

Nothing costs consumers more than error handling that varies per endpoint. Pick a shape on day one:

{
  "error": {
    "code": "validation_failed",
    "message": "The request could not be processed.",
    "details": [
      { "field": "email", "issue": "must be a valid email address" },
      { "field": "tags", "issue": "must contain at most 8 items" }
    ],
    "requestId": "req_9f2c1b7e"
  }
}
  • code is a stable machine-readable string. Clients branch on this. Never change one.
  • message is for humans and may change freely.
  • details is for field-level validation.
  • requestId correlates to your logs — the single most useful field in the whole envelope.

And use the status codes properly:

Code Meaning
400 Malformed request
401 Not authenticated
403 Authenticated, not allowed
404 Doesn't exist (or you may not know it does)
409 Conflict with current state
422 Well-formed but semantically invalid
429 Rate limited

Returning 200 { "success": false } is the one to avoid outright. It defeats HTTP caching, breaks every generic client, and means monitoring can't distinguish success from failure without parsing bodies.

Paginate from the start

Retrofitting pagination is a breaking change. Include it in v1 even when the collection has nine rows.

Offset pagination is fine for small, stable, page-numbered UIs:

GET /articles?page=2&limit=20

It has two real problems: it degrades linearly (the database walks and discards every skipped row), and it skips items when rows are inserted between requests — which shows up as users reporting missing records in an infinite scroll.

Cursor pagination fixes both:

GET /articles?limit=20
GET /articles?limit=20&cursor=eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDYtMTQifQ
{
  "data": [ /* … */ ],
  "pageInfo": {
    "nextCursor": "eyJwdWJsaXNoZWRBdCI6IjIwMjYtMDUtMzAifQ",
    "hasMore": true
  }
}

The cursor encodes the sort key of the last item, so the query becomes an indexed range scan rather than a skip. Opaque-encode it (base64) so nobody builds a client that parses it — that keeps you free to change the internals.

Always cap limit server-side. ?limit=100000 should silently become 100, not attempt to serialise the collection.

Idempotency for anything that costs money

Networks time out after the server has already committed. The client can't tell "never arrived" from "arrived, response lost" — so it retries, and you charge someone twice.

POST /payments
Idempotency-Key: 8f14e45f-ea51-4d3a-9c2b-1d7a0e3c9b52
export async function POST(req: Request) {
  const key = req.headers.get('idempotency-key');
  if (!key) {
    return error(400, 'idempotency_key_required');
  }
 
  const existing = await IdempotencyRecord.findOne({ key });
  if (existing) {
    // Replay the original response — do not re-execute
    return NextResponse.json(existing.response, { status: existing.status });
  }
 
  const result = await createPayment(await req.json());
 
  await IdempotencyRecord.create({
    key,
    response: result,
    status: 201,
    expiresAt: addHours(new Date(), 24),
  });
 
  return NextResponse.json(result, { status: 201 });
}

GET, PUT and DELETE are naturally idempotent. POST is not, and that's exactly where it matters.

Version at the edge, not everywhere

/v1/articles
/v2/articles

URL versioning wins on practicality: visible in logs, easy to route, trivial to curl. Header-based versioning (Accept: application/vnd.api.v2+json) is more theoretically correct and more annoying in every real debugging session.

But the important part isn't the scheme — it's not needing it. Most changes can be additive:

Safe Breaking
Adding a response field Removing or renaming a field
Adding an optional parameter Making a parameter required
Adding a new endpoint Changing a field's type
Adding an enum value clients ignore Changing status code semantics
Relaxing validation Tightening validation

Two habits keep you on the left column: never return a bare array at the top level (wrap it in { "data": [...] } so you can add pageInfo later without breaking parsers), and treat every field as permanent once shipped.

When you must break, run both versions, add a Sunset header to the old one, and give consumers a real window:

Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: </v2/articles>; rel="successor-version"

Small things that compound

ISO 8601, UTC, always. "2026-06-20T14:32:00Z". Never Unix timestamps in seconds — someone will parse them as milliseconds.

Strings for money and IDs. JavaScript's Number loses precision past 2^53. A 19-digit ID silently corrupts. Send "12345678901234567890".

One casing convention. camelCase for a JavaScript-first API. Consistency matters far more than which you pick.

Sparse fieldsets for heavy resources. GET /articles?fields=id,title,publishedAt — a list view shouldn't transfer full article bodies.

Rate limit headers on every response, so clients can back off before being rejected:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 43
X-RateLimit-Reset: 1750082400

Cache-Control on reads. A public list endpoint with s-maxage=300, stale-while-revalidate=600 removes most of its own load at the CDN.

The rule underneath all of it

Every response field, every error code, every parameter name is a promise you're making to code you don't control and can't update.

Ship less surface area. Wrap your responses so you have room to grow. Make errors boringly predictable. Paginate before you need to.

The API you can still evolve in year three is the one you were slightly conservative with in week one.