← Back to Blog
·10 min read

REST API JSON Design Is Full of Bad Habits — Here's What Good Looks Like

There is a clear pattern in well-designed APIs: they are boring. Consistent naming. Predictable structure. Explicit semantics. The nightmares are the creative ones — each endpoint a unique adventure in what "success" might mean this time. Here are the habits that separate the good from the nightmare, with strong opinions on each.

Naming conventions: pick one and enforce it religiously

The most common JSON API naming controversy is camelCase vs snake_case. Both are defensible. camelCase is natural for JavaScript clients, which is most clients. snake_case is natural for Python and Ruby backends. The choice matters less than the consistency.

What is not defensible: mixing both. An API that returns userId on one endpoint and user_id on another, or that uses camelCase for most fields but snake_case for fields added later, is forcing every client to maintain a lookup table of which convention applies where. It is a minor inconsistency that compounds into real friction at scale.

My position: camelCase for public APIs serving JavaScript clients. snake_case for internal APIs where the primary consumer is Python or Ruby. Either way, enforce it with a linter or serializer configuration, not with review comments that get ignored.

// Bad — mixed conventions
{
  "userId": 42,
  "user_name": "Alice",
  "createdAt": "2026-05-23T10:00:00Z",
  "last_login": "2026-05-22T18:30:00Z"
}

// Good — consistent camelCase
{
  "userId": 42,
  "userName": "Alice",
  "createdAt": "2026-05-23T10:00:00Z",
  "lastLogin": "2026-05-22T18:30:00Z"
}

Envelope responses: the right way and the wrong way

An "envelope" wraps the actual data in a containing object alongside metadata. There are two philosophies: envelope always, or envelope only for collections. Both are reasonable. What is not reasonable is the envelope-with-boolean-success pattern:

// Criminal — 200 OK with "success: false" in the body
HTTP/1.1 200 OK
{
  "success": false,
  "error": "User not found"
}

// This forces every client to check BOTH the HTTP status
// AND a body field. HTTP already has status codes.
// Use them.

// Better — direct object for single resources
HTTP/1.1 200 OK
{
  "id": 42,
  "username": "alice",
  "email": "alice@example.com"
}

// Better — envelope for collections (metadata is useful here)
HTTP/1.1 200 OK
{
  "data": [...],
  "pagination": {
    "cursor": "eyJpZCI6NDJ9",
    "hasMore": true,
    "total": 1847
  }
}

The crime is using HTTP 200 for failures. HTTP status codes exist precisely to communicate success or failure at the transport layer, where middleware, proxies, monitoring, and retry logic can act on them without parsing the body. A 200 with {"success": false} in the body is invisible to every layer of infrastructure between your server and your client.

Pagination: cursor beats offset every time

Offset-based pagination (?page=3&limit=20) is simple to implement and understand but has a fundamental correctness problem: if items are inserted or deleted between pages, you either skip records or return duplicates. At small scale this is theoretical. At any meaningful scale, with real-time data, it happens constantly.

Cursor-based pagination (?after=eyJpZCI6NDJ9) encodes a position in the dataset — typically an opaque token that the server encodes from the last record's sort key. The client does not need to understand or manipulate the cursor; it just passes it back. This approach is stable under insertions and deletions, scales to arbitrarily large datasets, and works naturally with infinite scroll patterns.

// Cursor-based pagination response
{
  "data": [
    { "id": 43, "username": "bob" },
    { "id": 44, "username": "carol" }
  ],
  "pagination": {
    "nextCursor": "eyJpZCI6NDR9",  // opaque — client treats as blob
    "prevCursor": "eyJpZCI6NDN9",
    "hasNextPage": true,
    "hasPrevPage": true
  }
}

// The cursor decodes server-side to: {"id": 44, "createdAt": "..."}
// Client never sees or manipulates this

Null vs omitted fields: take a position and document it

There is a semantic difference between a field that is null and a field that is absent. nullmeans "this field exists and its value is null." Absent means "this field does not apply to this resource." For many APIs, these are different things.

My strong preference: always include optional fields with null rather than omitting them. This makes the API contract explicit — clients know exactly what fields exist and can rely on them being present. Omitting fields creates an uncertain consumer experience where clients must use defensive optional chaining everywhere and guess whether a missing field means null or means the field does not exist at all.

// Prefer explicit nulls over omission
{
  "id": 42,
  "username": "alice",
  "displayName": null,    // has an account, hasn't set display name
  "avatarUrl": null,      // has an account, no avatar uploaded
  "lastLogin": "2026-05-22T18:30:00Z"
}

// Not this — "displayName" absent means what exactly?
{
  "id": 42,
  "username": "alice",
  "lastLogin": "2026-05-22T18:30:00Z"
}

Dates: ISO 8601 always, no exceptions

Dates in JSON have no native type — they are strings or numbers. This means teams make choices, and those choices are often wrong. Unix timestamps (integer seconds or milliseconds since epoch) are efficient and unambiguous about timezone, but they are unreadable in logs and require client-side conversion for display. Custom formats like "2026-05-23 10:00:00" are ambiguous about timezone and non-standard for parsers.

ISO 8601 with UTC timezone ("2026-05-23T10:00:00Z") is the right answer. It is human-readable, machine-parseable in every language, timezone-explicit, and sortable lexicographically. The RFC 3339 profile (used in HTTP headers, JWT claims, and most modern APIs) is a strict subset of ISO 8601 and is the specific format to target. There is no valid reason in 2026 to use anything else for timestamps in a JSON API.

Error responses: adopt RFC 7807 and stop reinventing this wheel

RFC 7807 (Problem Details for HTTP APIs) defines a standard JSON structure for API error responses. It has been available since 2016. The adoption rate among new APIs built by developers who are aware of it is embarrassingly low.

// RFC 7807 Problem Details — use this
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body contains invalid fields.",
  "instance": "/api/users/42",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address"
    },
    {
      "field": "username",
      "message": "Must be between 3 and 50 characters"
    }
  ]
}

// Instead of this homegrown thing that every team invents differently:
{
  "error": true,
  "msg": "bad data",
  "code": 1042
}

RFC 7807 gives you a URI-identified error type (linkable to documentation), a human-readable title, the HTTP status code (redundant but useful in logs), a specific detail message, and a request instance URI. Most importantly, it gives every client a predictable structure to parse. Error handling code written for one RFC 7807 API works with any other RFC 7807 API. That is the point of standards.

Published May 23, 2026 · By the utili.dev Team