JSON API Design Patterns: Building Better REST APIs
A well-designed API is a joy to use. A poorly designed one creates bugs, frustration, and technical debt. This guide covers battle-tested patterns for designing JSON REST APIs that are consistent, discoverable, and maintainable.
Resource Naming
Resources are nouns, not verbs. Use plural nouns for collections and singular resources via ID:
GET /api/users # List users
POST /api/users # Create a user
GET /api/users/123 # Get user 123
PUT /api/users/123 # Update user 123
DELETE /api/users/123 # Delete user 123
Naming conventions:
- Use lowercase with hyphens:
/api/blog-posts(notblogPostsorblog_posts) - Nest related resources:
/api/users/123/orders - Limit nesting to 2 levels:
/api/users/123/orders/456(not deeper) - Avoid verbs in URLs:
/api/users/123/activateis acceptable for actions that don't map to CRUD
Response Envelope
Wrap responses in a consistent envelope:
{
"data": {
"id": "123",
"type": "user",
"attributes": {
"name": "Alice",
"email": "alice@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
},
"meta": {
"requestId": "req_abc123"
}
}
For collections:
{
"data": [
{ "id": "123", "name": "Alice" },
{ "id": "456", "name": "Bob" }
],
"meta": {
"total": 142,
"page": 1,
"perPage": 20
}
}
Validate your API responses with our JSON Validator to ensure they match your schema.
Pagination
Three common approaches:
Offset-Based (Simplest)
GET /api/users?page=2&per_page=20
{
"data": [...],
"meta": {
"page": 2,
"perPage": 20,
"total": 142,
"totalPages": 8
}
}
Pro: Simple, supports jumping to any page. Con: Inconsistent results with concurrent inserts/deletes.
Cursor-Based (Most Reliable)
GET /api/users?cursor=eyJpZCI6MTIzfQ&limit=20
{
"data": [...],
"meta": {
"hasNext": true,
"nextCursor": "eyJpZCI6MTQzfQ"
}
}
Pro: Consistent with concurrent changes, performant on large datasets. Con: Cannot jump to arbitrary pages.
Keyset-Based (Most Performant)
GET /api/users?after_id=123&limit=20
Uses the last item's ID (or other sorted field) to fetch the next page. Similar to cursor-based but with transparent parameters.
Recommendation: Use cursor-based for real-time feeds and large datasets. Use offset-based for admin dashboards where page jumping matters.
Filtering and Sorting
Filtering
GET /api/users?status=active&role=admin
GET /api/users?created_after=2024-01-01
GET /api/users?search=alice
For complex filters, consider a dedicated query parameter:
GET /api/users?filter[status]=active&filter[role]=admin
Sorting
GET /api/users?sort=name # Ascending
GET /api/users?sort=-created_at # Descending (prefix with -)
GET /api/users?sort=-created_at,name # Multiple fields
Error Handling
Consistent error responses are critical for API usability:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be between 0 and 150",
"value": -5
}
]
},
"meta": {
"requestId": "req_abc123"
}
}
HTTP status codes to use:
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful GET, PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation errors |
| 401 | Unauthorized | Missing/invalid auth |
| 403 | Forbidden | Valid auth, insufficient permissions |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource, version conflict |
| 422 | Unprocessable | Semantically invalid |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Error | Unexpected server error |
Versioning
Three approaches:
URL Path (Recommended)
GET /api/v1/users
GET /api/v2/users
Pro: Explicit, easy to route, easy to test.
Header-Based
GET /api/users
Accept: application/vnd.myapi.v2+json
Pro: Clean URLs. Con: Harder to test, less discoverable.
Query Parameter
GET /api/users?version=2
Pro: Easy to test. Con: Optional parameter semantics.
Recommendation: URL path versioning is the most practical choice. It's explicit, cacheable, and works with every HTTP tool.
Date and Time Handling
Always use ISO 8601 in UTC:
{
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T14:22:33Z",
"expiresAt": "2024-12-31T23:59:59Z"
}
Never use Unix timestamps in API responses β they are ambiguous (seconds vs milliseconds) and not human-readable. For more on timestamp handling, see our Unix Timestamps guide.
Rate Limiting
Communicate rate limits in response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 67
X-RateLimit-Reset: 1705312800
Retry-After: 30
Return 429 Too Many Requests when the limit is exceeded, with a clear error message and Retry-After header.
HATEOAS (Optional but Powerful)
Include links to related resources and actions:
{
"data": {
"id": "123",
"name": "Alice",
"links": {
"self": "/api/users/123",
"orders": "/api/users/123/orders",
"avatar": "/api/users/123/avatar"
}
}
}
This makes your API self-documenting and reduces client-side URL construction.
FAQ
Should I use JSON:API specification or design my own format?
The JSON:API specification (jsonapi.org) provides a comprehensive standard, but it can be verbose for simple APIs. For most projects, designing a simpler custom format following the patterns in this guide is more practical. Use JSON:API if you need automatic client libraries and a strict standard.
How should I handle partial updates (PATCH vs PUT)?
Use PUT for complete resource replacement (client sends all fields). Use PATCH for partial updates (client sends only changed fields). PATCH with JSON Merge Patch (RFC 7396) is the simplest approach: send a JSON object with only the fields to update, and null to remove a field.
Related Resources
- JSON Formatter β Format API responses for readability
- JSON Schema Validation Guide β Validate API payloads with JSON Schema
- JWT Tokens Explained β Secure your APIs with JSON Web Tokens