JSON in RESTful API Design: Best Practices from Production

ยท 17 min read

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:

  1. Launch v2 alongside v1 (both active)
  2. Add deprecation warnings to v1 responses (6 months notice)
  3. Update documentation to recommend v2
  4. Monitor v1 usage, contact heavy users
  5. 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.