Skip to content
jsonforge.app
Back to blog
Guide10 min read

REST API JSON Design: Best Practices

The actual bytes a REST API returns matter as much as the routes and status codes — every client that ever calls your API is written against the shape of its JSON, and reshaping that JSON later is a breaking change no matter how minor it feels internally. This guide covers the response-design decisions that are cheap to get right up front and expensive to fix once clients exist.

Pick one envelope shape and never deviate

An envelope is the consistent top-level wrapper every response uses, regardless of endpoint: a known set of keys like `data`, `error`, and `meta` that a client's HTTP layer can handle generically instead of writing bespoke parsing per endpoint. Without one, every endpoint that returns a slightly different shape forces every client to write a slightly different parser.

A consistent envelope: success and error responses share the same top-level shape.
json
// Success
{
  "data": { "id": 42, "name": "Ada Lovelace" },
  "meta": { "requestId": "a1b2c3" }
}

// Failure
{
  "data": null,
  "error": { "code": "NOT_FOUND", "message": "User 42 does not exist" },
  "meta": { "requestId": "a1b2c4" }
}

The specific key names matter less than the consistency: whatever you choose, every endpoint returns it, `data` is always where the payload lives, and a client can write one `unwrap(response)` function for the entire API instead of one per resource.

Never return a bare array (or scalar) at the top level

`GET /users` returning a bare JSON array — `[{...}, {...}]` — looks natural, but it locks you out of ever adding response-level metadata without a breaking change. The moment you need a total count, a next-page cursor, or a warning message alongside the list, you have to change the top-level type from array to object, which breaks every client doing `response.map(...)` or `response.length` directly on the parsed body.

Wrap collections in a named field from day one — `{ "users": [...] }` or `{ "data": [...] }` — even when you're sure you'll never need extra metadata. Adding a sibling key to an object is a backward-compatible change; changing an array into an object is not, for any client, in any language.

Null vs. omitted: they are not the same signal

A field present with value `null` and a field left out of the response entirely both look like 'no value' at a glance, but they mean different things and clients need to be able to tell them apart. `null` says: this field is a real, known concept for this resource, and its current value is explicitly nothing (a user with no `middleName`). Omitted says: this field either doesn't apply, wasn't loaded, or wasn't requested (a sparse fieldset request that only asked for `id` and `name`).

This distinction is load-bearing in PATCH semantics specifically: under JSON Merge Patch (RFC 7396), sending a field as `null` means delete that field from the target, while simply not including the field in the patch body means leave it unchanged. Conflating the two — or being inconsistent about which fields can ever legitimately be `null` — produces APIs where clients can't safely tell 'clear this value' from 'I didn't send an update for this'.

Pagination: cursor vs. offset, and how each looks as JSON

Offset/limit pagination (`?offset=40&limit=20`, or `?page=3&pageSize=20`) is the simplest to implement and reason about, but it has a real correctness problem: if rows are inserted or deleted between page requests, offsets shift underneath the client, causing skipped or duplicated rows. It also gets expensive in SQL at deep offsets, since the database still has to scan and discard all the skipped rows.

Cursor-based pagination hands the client an opaque token pointing at 'the row after the last one you saw' instead of a numeric position, so it stays stable even as the underlying data changes, and stays cheap at any depth. The trade-off is that clients can't jump to an arbitrary page number — only forward (and, if you support it, backward) from a cursor.

A cursor-based pagination response — no offset math, just an opaque next-page token.
json
{
  "data": [ { "id": 101 }, { "id": 102 } ],
  "meta": {
    "nextCursor": "eyJpZCI6MTAyfQ==",
    "hasMore": true
  }
}

Default to cursor-based pagination for anything backed by frequently-changing data (feeds, logs, event streams) or large tables; offset-based pagination is fine for small, mostly-static collections where jump-to-page-N is genuinely useful (an admin table with a page picker).

Error objects and naming conventions

An error response should carry more than an HTTP status code and a string: a stable, machine-readable `code` a client can safely branch on (`"VALIDATION_ERROR"`, not a sentence), a human-readable `message` meant for logs and developers rather than parsing, and for validation failures, a `details` array naming exactly which fields failed and why.

An error shape that's actually actionable for a client.
json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request failed validation",
    "details": [
      { "field": "email", "message": "must be a valid email address" },
      { "field": "age", "message": "must be at least 0" }
    ]
  }
}

For key naming, camelCase and snake_case are both fine choices — what actually hurts is mixing them across endpoints of the same API, which forces every client to write inconsistent field-mapping logic depending on which endpoint they're calling. JavaScript/TypeScript-heavy consumers tend to prefer camelCase since it matches native destructuring; Python- and Ruby-heavy ecosystems often prefer snake_case. Pick one for the whole API, and apply it consistently to booleans too (`isActive`/`hasAccess`, not a mix of `active` and `has_access`).

Content-Type, charset, and versioning

Two mechanical details round out the contract. Serve `application/json`, the registered media type — unlike `text/plain`, it won't be charset-mangled by intermediaries; JSON is UTF-8 by definition (RFC 8259), so a charset parameter adds nothing. And plan versioning around JSON's compatibility rule: adding an optional field is safe for clients, while removing or renaming a field, changing its type, or narrowing its nullability is breaking. Path versioning (`/v1/`, `/v2/`) with a documented deprecation window (`Sunset` header) gives clients room to migrate.

Common anti-patterns

Stringified JSON inside a JSON field — `"metadata": "{\"plan\":\"pro\"}"` instead of `"metadata": { "plan": "pro" }` — forces every client to parse twice and throws away type safety for no benefit. It's almost always a sign the server serialized a database blob column verbatim instead of decoding it before sending the response.

Inconsistent date formats — some fields as Unix timestamps, others as `"08/15/2026"`, others as full ISO 8601 — force clients to write per-field date parsing instead of one shared date handler. Standardize on ISO 8601 in UTC with an explicit offset (`"2026-08-15T09:30:00Z"`) everywhere.

Leaking internal database fields — returning ORM artifacts like `password_hash`, internal-only foreign keys meant for another service, or ORM bookkeeping fields (`__v`, `_id`, `created_by_worker_id`) verbatim in an API response. Always map your database model to an explicit response DTO rather than serializing the model directly; it's the only reliable way to guarantee an internal schema change doesn't silently become a public API change.

FAQ

Should error responses use the same envelope as success responses?
Yes. If success responses are `{ "data": ..., "meta": ... }`, error responses should be the same top-level shape with `data: null` and an `error` object populated, rather than an entirely different structure. That lets a client's response-handling code check for one thing — the presence of `error` — instead of branching on response shape per status code.
camelCase or snake_case — does it actually matter which one I pick?
Not much on its own, but consistency matters a lot. Pick one convention for the entire API based on your primary consumers' ecosystem, document it, and apply it everywhere — including boolean prefixes and nested object keys. The cost isn't the convention itself, it's an API where different endpoints use different conventions and every client needs endpoint-specific mapping logic.
Should a new API use cursor or offset pagination?
Default to cursor-based pagination unless you specifically need to let users jump to an arbitrary page number in mostly-static, small data. Cursor pagination stays correct when rows are added or removed mid-pagination and stays cheap at any depth; offset pagination degrades in both correctness and performance as the dataset grows or changes.
Why shouldn't I just serialize my database model directly as the API response?
Because your database schema and your public API contract change for different reasons and on different timelines. Serializing the model directly means every migration, added column, or ORM upgrade risks silently changing — or leaking through — your public response shape. An explicit response DTO decouples the two, at the cost of one extra mapping step per endpoint.

Try these tools

Related articles