JSON Toolkit

Key Points for Designing JSON API Data Contracts

Bottom line: Six things are very hard to change once shipped: response envelopes, error formats, pagination shape, representation of IDs and time, the semantics of null versus absent, and version evolution.

Intermediate~13 min readPublished 2026-10-30Updated 2026-10-30
Key points for designing JSON API data contracts cover

Bottom line: lock down the response envelope, error format, ID and time representation, and null-versus-absent semantics in the contract before debating pagination and naming details; always wrap the top level in a data envelope so pagination and tracing IDs can be added later without breaking existing callers.

There are no standard answers in API design, but there are some "hard to take back once chosen" decisions that are best thought through before you write the first line of code.

1. To envelope the response or not

Three styles:

// Style A: bare response
{ "id": 1, "name": "张三" }

// Style B: data envelope
{ "data": { "id": 1, "name": "张三" } }

// Style C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
Style Pros Cons
Bare response Simplest, one less layer for the frontend Cannot attach metadata; a top-level array carries JSON hijacking and evolution risks
Data envelope Easy to add meta / errors / links One more nesting level, the frontend must write res.data.data
Data + metadata Best observability Requires strict agreement on which fields belong in meta

Recommendation: use a data envelope uniformly for both lists and details, and never make the top level an array. That way, adding pagination or tracing IDs later never breaks the contract.

2. Error format: copy RFC 9457

Inventing your own error structure is a huge source of collaboration cost. RFC 9457 (which replaces RFC 7807) defines application/problem+json:

{
  "type": "https://bbdrug.com/problems/insufficient-balance",
  "title": "余额不足",
  "status": 422,
  "detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
  "instance": "/accounts/A1001/payments/p-889",
  "balance": "12.00",
  "required": "30.00"
}
Field Meaning Required
type Stable problem-type URI Recommended
title Short human-readable summary Recommended
status HTTP status code Recommended
detail Specific explanation of what happened this time Optional
instance The resource instance that had the problem Optional
Extension fields Arbitrary business fields Optional

Extension fields (such as balance / required above) are allowed by the spec, and the frontend can use them for precise messages instead of parsing the detail text.

3. Pagination: cursor beats offset

// Offset pagination
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }

// Cursor pagination
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
Dimension Offset pagination Cursor pagination
Implementation difficulty Low (LIMIT/OFFSET) Medium (requires a stable sort key)
Performance on deep pages Poor (the larger the offset, the slower) Good
When data changes Duplicates or missing items Stable
Jump to a page Possible Not possible
Needs a total count Requires an extra count Not required

Recommendation: use offset pagination for admin tables (they need page jumps and totals), and cursor pagination for user-facing infinite feeds.

4. IDs and time

4.1 Always use strings for IDs

The reasoning was covered in the article on types: on the JavaScript side, values above 2^53 silently lose precision. Even if the current ID is a small auto-increment integer, using strings leaves room for switching to snowflake IDs later.

{ "id": "1000000000000000001" }

4.2 Use RFC 3339 strings for time

{
  "createdAt": "2026-10-30T10:41:45+08:00",
  "updatedAt": "2026-10-30T02:41:45Z"
}
  • Do not use millisecond timestamp numbers: they are hard to read, and the unit (seconds/milliseconds) is frequently mistaken;
  • Specify the time zone explicitly: either with Z or with an offset; never use "local time without a time zone";
  • Name fields uniformly as xxxAt so it is obvious at a glance that it is a point in time.

5. null and absent

You must pick one and write it into the documentation:

Approach Expressing "no value" Expressing "do not modify"
Approach A The field value is null Omit the field in the request
Approach B The field does not exist Omit the field in the request

The problem with Approach A is that "omitted" and "sent as null" mean different things in a PATCH, which is easy to misinterpret; the problem with Approach B is that the frontend's JSON.stringify drops fields whose value is undefined, which happens to match the semantics.

Practical recommendation:

  • Use Approach A for response bodies (null clearly means no value, and the frontend does not need to distinguish undefined);
  • Use Approach B for request bodies (especially PATCH), combined with Merge Patch or JSON Patch to make the semantics explicit.

6. Field naming and structure

Decision Common choices Caveat
Naming style camelCase (JS ecosystem) / snake_case (Python, databases) Uniform across the project, do not mix
Enum values Lowercase with underscores, "pending_review" Do not use numbers; they are hard to read
Boolean fields isActive / enabled Do not use status: 0/1
Array fields Plural nouns, items Return [] for empty arrays, never null
Nesting depth No more than 3 levels Deeper than that, split it up or offer an expand parameter

Returning [] instead of null for empty arrays matters a lot: data.items.map(...) on the frontend will not crash because of a null.

7. Evolution strategy

Change Breaking compatibility How to handle
Add an optional field No Ship it directly
Add a required field Yes Add a default value, or release by version
Remove a field Yes Mark it deprecated first, observe callers, then remove
Change a field type Yes Add a new field name and dual-write during the transition
Change the enum value set Depends The client must have a fallback for unknown values
Change field semantics Yes Treat it as a new field

Three rules of discipline:

  1. Clients must be tolerant of unknown fields (do not box yourself in with additionalProperties: false);
  2. Clients must have a fallback branch for unknown enum values;
  3. Keep deprecated fields for at least one major version, and mark them with Deprecation in the response headers or documentation.

8. A complete example

{
  "data": [
    {
      "id": "1001",
      "name": "JSON 权威指南",
      "price": "89.50",
      "tags": ["json", "spec"],
      "publishedAt": "2026-09-01T00:00:00+08:00",
      "author": null
    }
  ],
  "page": { "nextCursor": null, "hasMore": false },
  "meta": { "requestId": "req-7f3a", "tookMs": 12 }
}

This response reflects all the conventions above: data envelope, string IDs, amounts as strings, timestamped with a time zone, null meaning missing, empty array, cursor pagination, and meta for observability information.

9. Checklist

  • [ ] The top level is not an array; use a data envelope uniformly;
  • [ ] Error responses follow RFC 9457 and include a stable type;
  • [ ] The pagination shape matches the business scenario;
  • [ ] IDs are strings, and time uses RFC 3339;
  • [ ] The semantics of null and absent are written into the documentation;
  • [ ] Naming style is uniform across the project, and enums use lowercase strings;
  • [ ] The client has fallbacks for unknown fields and unknown enum values;
  • [ ] Breaking changes go through versioning or a dual-write transition.

FAQ

How should I design an evolvable JSON API?

Wrap lists and details uniformly in a data envelope and never make the top level an array, so pagination and tracing IDs can be added without breaking the contract. Use string IDs and RFC 3339 timestamps, and write the null-versus-absent semantics into the documentation. Keep new fields optional and semantically independent, and give clients fallbacks for unknown fields and enum values.

Why not invent a custom error format?

Inventing your own error structure is a huge source of collaboration cost. Copy RFC 9457's application/problem+json, identify problems with a stable type, and allow extension fields such as balance and required, so the frontend can build precise messages without parsing the detail text.

Should every endpoint use cursor pagination?

No. Admin tables need page jumps and totals, so offset pagination fits better; user-facing infinite feeds should use cursor pagination, which stays stable on deep pages and avoids duplicates or gaps when data changes. Choose based on whether the scenario needs page jumps and totals.