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

REST API JSON Design: Best Practices

The bytes a REST API returns matter as much as the routes and status codes. Every client that calls your API is written against the shape of its JSON, and reshaping that JSON later breaks them, however minor the change feels internally. This guide covers the response-design decisions that cost little to get right up front and a lot to fix once clients exist.

Pick one envelope shape and stick to it

An envelope is the 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 parsing each endpoint differently. Without one, each endpoint with a slightly different shape forces each 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 key names matter less than consistency. Whatever you choose, every endpoint returns it, `data` holds the payload every time, and a client can write one `unwrap(response)` function for the whole API instead of one per resource.

Don't return a bare array (or scalar) at the top level

A `GET /users` that returns a bare JSON array (`[{...}, {...}]`) looks natural, but you can't add response-level metadata later without a breaking change. Once you need a total count, a next-page cursor, or a warning alongside the list, you have to change the top-level type from array to object, which breaks every client calling `response.map(...)` or `response.length` 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 stays backward-compatible. Changing an array into an object breaks every client in every language.

Null and omitted send different signals

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

PATCH semantics depend on this distinction. Under JSON Merge Patch (RFC 7396), sending a field as `null` deletes that field from the target, while leaving the field out of the patch body leaves it unchanged. If you conflate the two, or stay vague about which fields can be `null`, clients can't 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 correctness problem: if someone inserts or deletes rows between page requests, offsets shift under the client, and it skips or duplicates rows. It also gets expensive in SQL at deep offsets, since the database still scans and discards 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 results stay stable as the data changes and queries stay cheap at any depth. The trade-off: clients can't jump to an arbitrary page number. They move forward (and, if you support it, backward) from a cursor.

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

Default to cursor-based pagination for frequently-changing data (feeds, logs, event streams) or large tables. Offset-based pagination works for small, mostly-static collections where jumping to page N helps (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. Give it a stable, machine-readable `code` a client can branch on (`"VALIDATION_ERROR"`, not a sentence), a human-readable `message` for logs and developers, and for validation failures, a `details` array naming which fields failed and why.

An error shape a client can act on.
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 both work. Mixing them across endpoints of the same API hurts, because each client then needs field-mapping logic that depends on the endpoint. JavaScript/TypeScript consumers lean toward camelCase since it matches native destructuring; Python and Ruby ecosystems often prefer snake_case. Pick one for the whole API, and apply it 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. Intermediaries won't mangle its charset the way they can with `text/plain`, and JSON is UTF-8 by definition (RFC 8259), so a charset parameter adds nothing. 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 breaks them. 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 usually means 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 means returning ORM artifacts like `password_hash`, internal foreign keys meant for another service, or bookkeeping fields (`__v`, `_id`, `created_by_worker_id`) verbatim in an API response. Map your database model to an explicit response DTO instead of serializing the model. A DTO is the reliable way to stop an internal schema change from turning into 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 keep that top-level shape with `data: null` and an `error` object populated. A client's response-handling code then checks one thing, the presence of `error`, instead of branching on response shape per status code.
camelCase or snake_case: does the choice matter?
The convention itself matters little; consistency matters a lot. Pick one convention for the whole API based on your main consumers' ecosystem, document it, and apply it everywhere, including boolean prefixes and nested object keys. The cost comes from an API where 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 users need to jump to an arbitrary page number in small, mostly-static data. Cursor pagination stays correct when rows change mid-pagination and stays cheap at any depth. Offset pagination degrades in both correctness and performance as the dataset grows or changes.
Why not serialize my database model directly as the API response?
Your database schema and your public API contract change for different reasons on different timelines. If you serialize the model directly, any migration, added column, or ORM upgrade can change or leak your public response shape without warning. An explicit response DTO decouples the two, at the cost of one extra mapping step per endpoint.

Try these tools

Related articles