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.

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
Zor with an offset; never use "local time without a time zone"; - Name fields uniformly as
xxxAtso 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 (
nullclearly means no value, and the frontend does not need to distinguishundefined); - 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:
- Clients must be tolerant of unknown fields (do not box yourself in with
additionalProperties: false); - Clients must have a fallback branch for unknown enum values;
- Keep deprecated fields for at least one major version, and mark them with
Deprecationin 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
dataenvelope 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
nulland 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.