JSON in REST APIs: How to Structure, Paginate, and Secure Your Data
JSON became the default format for REST APIs not because anyone mandated it, but because it solved the right problems at the right time. It is lightweight compared to XML, native to JavaScript (the language of the web), and human-readable enough that you can glance at a response and understand the data without special tooling.
Today, the biggest APIs on the internet - GitHub, Stripe, Twilio, OpenAI - all speak JSON. But returning a JSON blob from an endpoint is the easy part. The hard part is doing it correctly: structuring responses consistently, paginating large collections, handling errors gracefully, and keeping your data secure. That is what this post covers.
Content-Type Headers Matter
When you send JSON in an HTTP request, set the Content-Type header to application/json so the server knows how to parse the body. When you receive JSON, the server should return the same content type. The Accept header tells the server what format you want back - some APIs support multiple formats and use this header to decide which one to return:
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ name: "Alice", role: "admin" })
});If you leave out the Accept header, most modern APIs default to JSON, but it is good practice to be explicit. Middleware, proxies, and client libraries all rely on these headers to decide how to handle the body.
Structuring JSON Responses
There is no universal standard for JSON response structure, but certain conventions are widely adopted. For a single resource, return its fields directly:
{
"id": 42,
"name": "Alice Chen",
"email": "alice@example.com",
"role": "admin",
"createdAt": "2025-03-15T08:30:00Z"
}For a collection of resources, wrap the array in an object. Returning a bare array as the top-level response is generally avoided because it makes it harder to add metadata later, and historically had JSON hijacking implications:
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"total": 47,
"page": 1,
"perPage": 20
}
}Wrapping collections in a data key gives you room to add meta, pagination, or links without breaking existing clients.
HTTP Status Codes and JSON
The HTTP status code and the JSON body should tell a consistent story. Here are the most common pairings:
- 200 OK - successful GET, PUT, or PATCH. Return the resource.
- 201 Created - successful POST that created a resource. Return the created resource with its new ID.
- 204 No Content - successful DELETE. No JSON body at all.
- 400 Bad Request - the JSON body was malformed or failed validation.
- 401 Unauthorized - missing or invalid authentication.
- 404 Not Found - the resource does not exist.
- 422 Unprocessable Entity - the JSON was valid, but the data did not pass business rules.
- 500 Internal Server Error - something broke server-side.
A common anti-pattern is returning a 200 status code with an error message inside the JSON body. This forces clients to check both the status code and the body to determine if the request succeeded. Use the correct HTTP status code and reserve the body for details - do not mix the two.
Error Response Formats
A well-structured error response should include enough detail for the client to understand what went wrong and how to fix it:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request body failed validation",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
},
{
"field": "age",
"message": "Must be a positive integer"
}
]
}
}The code field gives clients a machine-readable identifier they can match on. The message field is human-readable. The details array breaks down field-level issues so clients can highlight specific form inputs.
RFC 7807 ("Problem Details for HTTP APIs") is gaining traction as a standardized approach. It defines fields like type, title, status, detail, and instance. Worth considering if you are designing a new API from scratch.
Pagination Patterns
Three pagination strategies are common in JSON APIs, each with different tradeoffs:
Offset-based pagination is the simplest. The client sends page and perPage (or offset and limit) query parameters:
GET /api/users?page=3&perPage=20
{
"data": [...],
"pagination": {
"page": 3,
"perPage": 20,
"total": 247,
"totalPages": 13
}
}The downside is that offset queries get slower on large tables and inserting or deleting rows between requests can cause items to be skipped or duplicated.
Cursor-based paginationis better for large or frequently-changing datasets. Instead of a page number, the client sends a cursor - usually an opaque token or the last item's ID:
GET /api/events?cursor=eyJpZCI6MTAwfQ&limit=50
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6MTUwfQ",
"hasMore": true
}
}Cursor pagination is more performant at scale and immune to the row-shift problem, but clients cannot jump to an arbitrary page.
Link-header pagination puts navigation URLs in the HTTP Linkheader rather than the JSON body. GitHub's API uses this approach. It keeps the response body clean but requires clients to parse the header.
Nested vs. Flat Structures
Should related resources be nested inline or referenced by ID? Both approaches have tradeoffs:
// Nested - fewer requests, but potentially large payloads
{
"id": 1,
"title": "My Post",
"author": {
"id": 42,
"name": "Alice",
"avatar": "https://..."
},
"comments": [
{ "id": 100, "body": "Great post!", "author": { "id": 43, "name": "Bob" } }
]
}
// Flat - smaller payloads, but requires additional requests
{
"id": 1,
"title": "My Post",
"authorId": 42,
"commentIds": [100, 101, 102]
}The middle ground that many APIs settle on: include one level of nesting for commonly-needed related data and use IDs for everything else. Some APIs let clients choose through query parameters like ?include=author,comments or GraphQL-style field selection.
API Versioning
When your JSON response structure changes in a breaking way, you need versioning. The three main approaches:
- URL path versioning:
/api/v2/users- the most common and most visible approach. - Header versioning:
Accept: application/vnd.myapi.v2+json- cleaner URLs but harder to test in a browser. - Query parameter:
/api/users?version=2- simple but easy to forget.
URL path versioning is the most widely adopted because it is explicit, easy to route on the server, and obvious in documentation. Whichever you choose, document it clearly and give clients a migration window before removing old versions.
Practical Example
Here is a realistic createUser function demonstrating proper headers, error handling, and response parsing:
async function createUser(userData) {
const response = await fetch("https://api.example.com/v1/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify(userData)
});
// Handle specific error cases
if (response.status === 422) {
const body = await response.json();
throw new ValidationError(body.error.details);
}
if (response.status === 401) {
throw new AuthError("Token expired or invalid");
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new ApiError(response.status, body?.error?.message ?? "Unknown error");
}
// 201 Created - return the new user
return response.json();
}Notice the pattern: check for specific status codes you can handle meaningfully before falling through to a generic !response.ok check. Always guard response.json() on the error path - a 502 from a proxy might return HTML instead of JSON.
Best Practices Summary
- Use consistent property naming.
camelCaseis standard in JavaScript APIs,snake_caseis common in Python and Ruby APIs. Pick one convention and stick with it across every endpoint. - Return dates as ISO 8601 strings (
2026-03-05T08:30:00Z). Never use Unix timestamps unless your documentation makes it extremely clear. - Use
nullfor absent values, not empty strings or the number zero. There is a semantic difference between "no value" and "blank value." - Include a
requestIdin error responses so support teams can trace issues through logs and correlate client reports with server-side events. - Do not return more data than the client needs. Over-fetching wastes bandwidth and increases the risk of leaking sensitive fields.
- Validate and pretty-print your API responses during development to catch structural problems early.
For a deeper look at how JSON compares to the XML format it replaced, read our JSON vs XML comparison. And when your API responses need to be as compact as possible, our guide on how to minify JSON covers techniques for reducing payload size without sacrificing readability during development.
Debug API Responses Instantly
Debugging an API response? Paste the raw JSON into our formatter to instantly see the structure with syntax highlighting.
Open JSON Prettifier