JSON in RESTful API Design: Best Practices from Production
When building APIs for our e-commerce platform, I quickly learned that JSON structure matters as much as the business logic behind it. A well-designed JSON API is intuitive, consistent, and scalable. A poorly designed one creates frustration, bugs, and expensive rewrites.
This guide shares the RESTful API design patterns I've learned from building and maintaining production systems handling thousands of requests daily. These patterns come from real experience, not just theory.
REST Principles and JSON
When building our first public API, I thought REST was just about using HTTP methods correctly. I was wrong. REST is an architectural style that defines how resources are represented and accessed, and JSON is the perfect format for that representation.
Resources over actions
A common mistake I see is designing APIs around actions instead of resources:
// Bad - action-oriented
POST /api/createUser
POST /api/deleteUser
GET /api/getUserById?id=123
// Good - resource-oriented
POST /api/users
DELETE /api/users/123
GET /api/users/123
The resource-oriented approach is cleaner and more predictable. Every developer knows
what GET /api/users/123 does without reading documentation.
HTTP methods map to operations
The HTTP method tells you what operation to perform:
- GET - Retrieve a resource (safe, idempotent)
- POST - Create a new resource
- PUT - Replace an entire resource (idempotent)
- PATCH - Partially update a resource
- DELETE - Remove a resource (idempotent)
Stateless communication
Each request contains everything the server needs to process it. When building our authentication system, we used JWT tokens in headers rather than server-side sessions. This made scaling horizontally trivial.
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Structuring JSON Responses
Consistency in response structure reduces bugs and speeds up frontend development. When building our API, I established these patterns early and stuck to them rigorously.
Envelope vs direct response
Two common approaches:
Direct response (recommended for REST)
// GET /api/users/123
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"created_at": "2026-01-15T10:30:00Z"
}
Envelope response
// Alternative envelope pattern
{
"status": "success",
"data": {
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"created_at": "2026-01-15T10:30:00Z"
},
"meta": {
"timestamp": "2026-02-07T14:22:00Z"
}
}
I prefer direct responses for most endpoints. HTTP status codes already indicate success
or failure, so wrapping everything in a status field is redundant. Use
envelopes only when you need to include metadata that doesn't fit in headers.
Collection responses
When returning multiple items, always use an array at the root:
// GET /api/users
[
{
"id": 123,
"name": "John Doe"
},
{
"id": 124,
"name": "Jane Smith"
}
]
For paginated collections, wrap with metadata:
// GET /api/users?page=2&limit=20
{
"data": [
{"id": 123, "name": "John Doe"},
{"id": 124, "name": "Jane Smith"}
],
"pagination": {
"page": 2,
"limit": 20,
"total": 450,
"pages": 23
}
}
Consistent naming conventions
Pick a convention and stick to it. I use snake_case for field names because
it's more readable and common in database schemas:
{
"user_id": 123,
"first_name": "John",
"last_name": "Doe",
"created_at": "2026-01-15T10:30:00Z",
"is_active": true
}
Some teams prefer camelCase. Either is fine, but mixing both creates
confusion and bugs.
Pagination Patterns
When our product catalog grew past 10,000 items, returning everything in one request became impossible. I implemented three pagination strategies depending on use case.
Offset-based pagination
The simplest approach. Good for small to medium datasets with stable ordering.
GET /api/products?page=3&limit=20
{
"data": [...],
"pagination": {
"page": 3,
"limit": 20,
"total": 10247,
"pages": 513,
"prev": "/api/products?page=2&limit=20",
"next": "/api/products?page=4&limit=20"
}
}
Pros: Simple to implement, easy to jump to any page
Cons: Performance degrades with high offsets (page 500 is slow), items can be skipped or duplicated if data changes between requests
Cursor-based pagination
Better for large datasets and real-time feeds. This is what I used for our activity feed and chat messages.
GET /api/messages?cursor=eyJpZCI6MTIzNDUsInRzIjoxNzA2MzY1MjAwfQ&limit=50
{
"data": [
{
"id": 12345,
"text": "Hello world",
"created_at": "2026-02-07T10:30:00Z"
}
],
"pagination": {
"next_cursor": "eyJpZCI6MTIzMDAsInRzIjoxNzA2MzY0ODAwfQ",
"prev_cursor": "eyJpZCI6MTIzODAsInRzIjoxNzA2MzY1NDAwfQ",
"has_more": true
}
}
The cursor is typically a base64-encoded JSON object containing the last item's ID and timestamp. This ensures consistent results even when new items are added.
Pros: Consistent results, excellent performance at any depth
Cons: Can't jump to arbitrary pages, cursors become invalid if data is deleted
Keyset-based pagination
The most performant option for ordered datasets. Uses the last item's key to fetch the next page.
GET /api/products?after_id=5420&limit=100
{
"data": [...],
"pagination": {
"after_id": 5520,
"limit": 100,
"has_more": true
}
}
SQL query implementation:
SELECT * FROM products
WHERE id > 5420
ORDER BY id ASC
LIMIT 100;
Pros: Extremely fast, works with database indexes perfectly
Cons: Only works for forward pagination, requires stable ordering column
When to use each pattern
- Offset pagination: Admin dashboards, reports, small datasets (under 10,000 items)
- Cursor pagination: Social feeds, infinite scrolling, real-time data
- Keyset pagination: Bulk data exports, high-volume APIs, syncing systems
Error Response Formats
Good error responses save hours of debugging. When building our API, I adopted RFC 7807 Problem Details for HTTP APIs, which provides a standard error format.
RFC 7807 Problem Details format
// 400 Bad Request
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "The request body contains invalid fields",
"instance": "/api/users",
"errors": [
{
"field": "email",
"message": "Invalid email format"
},
{
"field": "age",
"message": "Must be at least 18"
}
]
}
Common error status codes
- 400 Bad Request - Client sent invalid data
- 401 Unauthorized - Authentication required or failed
- 403 Forbidden - Authenticated but lacks permission
- 404 Not Found - Resource doesn't exist
- 409 Conflict - Request conflicts with current state (e.g., duplicate email)
- 422 Unprocessable Entity - Validation failed
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Server-side error
- 503 Service Unavailable - Temporary outage or maintenance
Validation error example
When creating a user fails validation:
// POST /api/users - 422 Unprocessable Entity
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Failed",
"status": 422,
"detail": "One or more fields failed validation",
"errors": [
{
"field": "email",
"code": "FORMAT_INVALID",
"message": "Email must be a valid email address"
},
{
"field": "password",
"code": "TOO_SHORT",
"message": "Password must be at least 8 characters"
}
]
}
Rate limiting error
// 429 Too Many Requests
{
"type": "https://api.example.com/errors/rate-limit",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the rate limit of 100 requests per minute",
"retry_after": 45
}
Include Retry-After header as well:
Retry-After: 45
HATEOAS and Self-Documenting APIs
HATEOAS (Hypermedia as the Engine of Application State) means including links in your responses that tell clients what actions they can take next. When I first implemented this, frontend developers loved not having to hard-code URLs.
Basic HATEOAS example
// GET /api/orders/789
{
"id": 789,
"status": "pending",
"total": 89.99,
"items": [...],
"links": {
"self": "/api/orders/789",
"customer": "/api/customers/123",
"cancel": "/api/orders/789/cancel",
"pay": "/api/orders/789/payment"
}
}
The links object tells the client exactly what operations are available. If
the order is already paid, the pay link wouldn't be included.
Dynamic actions based on state
For a shipped order, available actions change:
// GET /api/orders/789
{
"id": 789,
"status": "shipped",
"total": 89.99,
"tracking_number": "1Z999AA10123456784",
"links": {
"self": "/api/orders/789",
"customer": "/api/customers/123",
"track": "/api/orders/789/tracking",
"return": "/api/orders/789/return"
}
}
Notice cancel and pay are gone because they're no longer valid
actions. This prevents the client from attempting invalid operations.
HAL format for hypermedia
HAL (Hypertext Application Language) is a popular standard I've used in production:
{
"_links": {
"self": {"href": "/api/orders/789"},
"customer": {"href": "/api/customers/123"},
"items": {"href": "/api/orders/789/items"}
},
"id": 789,
"status": "pending",
"total": 89.99,
"_embedded": {
"customer": {
"id": 123,
"name": "John Doe",
"_links": {
"self": {"href": "/api/customers/123"}
}
}
}
}
HAL separates links and embedded resources from the main data, making it clear what's a link vs actual data.
Benefits I've observed
- Frontend doesn't hard-code URLs - easier to refactor
- API changes are less breaking - just update links
- Self-documenting - developers discover capabilities through exploration
- State machines become explicit - links show valid transitions
API Versioning Strategies
When we needed to make breaking changes to our API, I had to choose a versioning strategy. I tried three different approaches across projects. Here's what I learned.
URL path versioning
The most common approach. Put the version in the URL:
GET /api/v1/users/123
GET /api/v2/users/123
Pros: Explicit, easy to route, works with all HTTP clients
Cons: Feels un-RESTful (same resource has different URLs), cache keys differ per version
Header versioning
Use a custom header to specify version:
GET /api/users/123
API-Version: 2
Or use Accept header with vendor media type:
GET /api/users/123
Accept: application/vnd.example.v2+json
Pros: RESTful (same URL for same resource), cleaner URLs
Cons: Harder to test in browser, requires custom header support, caching complexity
Query parameter versioning
GET /api/users/123?version=2
Pros: Easy to test, backward compatible
Cons: Mixing versioning with filtering parameters feels messy
What I recommend
Use URL path versioning for public APIs. It's the most explicit and works everywhere. Use header versioning for internal APIs where you control all clients.
Version only when you have breaking changes:
- Removing or renaming fields
- Changing field types (string to number)
- Changing behavior of existing endpoints
These are NOT breaking changes and don't require new versions:
- Adding new optional fields
- Adding new endpoints
- Fixing bugs
- Adding new query parameters
Deprecation strategy
When introducing v2, I followed this timeline:
- Launch v2 alongside v1 (both active)
- Add deprecation warnings to v1 responses (6 months notice)
- Update documentation to recommend v2
- Monitor v1 usage, contact heavy users
- Shut down v1 after usage drops below 5%
Example deprecation header:
Deprecation: true
Sunset: Sat, 31 Aug 2026 23:59:59 GMT
Link: </api/v2/users/123>; rel="alternate"
Real API Examples
Let's look at how major companies structure their JSON APIs. I've integrated with dozens of APIs and these are consistently the best designed.
GitHub API
Excellent use of HATEOAS and pagination:
// GET /repos/facebook/react/issues?page=1&per_page=30
[
{
"id": 28457823,
"number": 12345,
"title": "Bug in useEffect",
"state": "open",
"user": {
"login": "octocat",
"id": 1,
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"url": "https://api.github.com/users/octocat"
},
"labels": [
{"name": "bug", "color": "d73a4a"}
],
"created_at": "2026-02-01T10:00:00Z",
"updated_at": "2026-02-07T14:30:00Z"
}
]
Pagination in Link header:
Link: <https://api.github.com/repos/facebook/react/issues?page=2&per_page=30>; rel="next",
<https://api.github.com/repos/facebook/react/issues?page=10&per_page=30>; rel="last"
Stripe API
Clean pagination with expandable resources:
// GET /v1/charges?limit=3
{
"object": "list",
"data": [
{
"id": "ch_3MmlLrLkdIwHu7ix0SNM0Sa",
"amount": 2000,
"currency": "usd",
"status": "succeeded",
"customer": "cus_123",
"created": 1675440000
}
],
"has_more": true,
"url": "/v1/charges"
}
Expandable resources avoid multiple API calls:
GET /v1/charges/ch_3MmlLrLkdIwHu7ix0SNM0Sa?expand[]=customer
{
"id": "ch_3MmlLrLkdIwHu7ix0SNM0Sa",
"amount": 2000,
"customer": {
"id": "cus_123",
"email": "customer@example.com",
"name": "John Doe"
}
}
Twitter API v2
Uses expansions and field selection for flexible responses:
GET /2/tweets?ids=1234567890&expansions=author_id&tweet.fields=created_at,public_metrics
{
"data": [
{
"id": "1234567890",
"text": "Hello world",
"author_id": "987654321",
"created_at": "2026-02-07T10:30:00.000Z",
"public_metrics": {
"retweet_count": 10,
"like_count": 50
}
}
],
"includes": {
"users": [
{
"id": "987654321",
"name": "John Doe",
"username": "johndoe"
}
]
}
}
This pattern separates primary data from included related resources, reducing duplication when multiple tweets have the same author.
Design Best Practices from Production
These are the principles I follow when designing any new API endpoint.
Use consistent HTTP status codes
Don't return 200 OK for errors with an error object in the body. Use proper codes:
// Bad
200 OK
{"status": "error", "message": "User not found"}
// Good
404 Not Found
{"type": "...", "title": "User Not Found", "status": 404}
Make responses predictable
The same endpoint should always return the same shape. Don't do this:
// Bad - different shapes based on data
// When user has orders:
{"id": 123, "name": "John", "orders": [...]}
// When user has no orders:
{"id": 123, "name": "John", "orders": null}
// Good - always return array
{"id": 123, "name": "John", "orders": []}
Include timestamps
Always use ISO 8601 format with timezone:
{
"created_at": "2026-02-07T14:30:00Z",
"updated_at": "2026-02-07T14:30:00Z"
}
Partial responses for performance
Let clients request only the fields they need:
GET /api/users/123?fields=id,name,email
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
Bulk operations
Support batch requests to reduce round trips:
POST /api/users/bulk
{
"operations": [
{"method": "POST", "path": "/api/users", "body": {"name": "John"}},
{"method": "PUT", "path": "/api/users/123", "body": {"name": "Jane"}},
{"method": "DELETE", "path": "/api/users/456"}
]
}
Rate limiting headers
Tell clients about rate limits in every response:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1707315600
Documentation and OpenAPI
Use OpenAPI (formerly Swagger) to document your API. It generates interactive docs and can generate client SDKs automatically. I've saved countless hours of documentation work by maintaining an OpenAPI spec.