REST API Design Best Practices: The Patterns That Make APIs a Joy to Use

REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
41 views

A well-designed REST API is a pleasure to integrate with. A poorly designed one is a source of bugs, support tickets, and developer frustration that lasts for years. These best practices cover the decisions that matter most: versioning, error formats, pagination, idempotency, filtering, and the workflow that prevents breaking changes before they reach production.

⚡ TL;DR: Use URL versioning (/v1/). Return consistent error objects with type, title, detail, instance. Use cursor pagination not offset. Accept idempotency keys on mutating requests. Return rate limit headers on every response. Write the OpenAPI spec before writing code.

URL structure and naming conventions

# Resources are nouns, not verbs
# Collection: /users (plural)
# Instance: /users/{id} (not /user/{id})
# Nested: /users/{id}/orders (relationship)
# Actions: /users/{id}/activate (use verbs only for actions, not CRUD)

# HTTP methods map to operations:
GET /users → List users
POST /users → Create user
GET /users/{id} → Get user
PUT /users/{id} → Replace user (full update)
PATCH /users/{id} → Update user (partial update)
DELETE /users/{id} → Delete user

# Filtering, searching, sorting via query params:
GET /orders?status=pending&sort=-created_at&limit=20&cursor=xxx
# NOT: GET /getOrdersByStatus?status=pending (verbs = bad)

# URL versioning — simplest, most explicit:
GET /v1/users
# Not header versioning (invisible) or subdomain (DNS overhead)

Consistent error responses

// Use RFC 7807 Problem Details format — standardized, tooling support
// Status: 422 Unprocessable Entity
{
 "type": "https://api.example.com/errors/validation-failed",
 "title": "Validation Failed",
 "status": 422,
 "detail": "The request body failed validation",
 "instance": "/v1/users/create/trace-abc-123",
 "errors": [
 {
 "field": "email",
 "code": "EMAIL_INVALID",
 "message": "Must be a valid email address"
 },
 {
 "field": "age",
 "code": "VALUE_OUT_OF_RANGE",
 "message": "Must be between 18 and 120"
 }
 ]
}

// HTTP status codes used correctly:
// 200 OK — success
// 201 Created — resource created (include Location header)
// 204 No Content — success with no response body
// 400 Bad Request — client syntax error
// 401 Unauthorized — not authenticated
// 403 Forbidden — authenticated but not authorized
// 404 Not Found — resource does not exist
// 409 Conflict — state conflict (duplicate, version mismatch)
// 422 Unprocessable — semantically invalid input
// 429 Too Many Requests — rate limited
// 500 Internal Server Error — server bug

Pagination — cursor beats offset

// Response envelope with pagination metadata:
{
 "data": [...],
 "pagination": {
 "limit": 20,
 "nextCursor": "eyJpZCI6MTAwMH0=", // Base64 encoded cursor
 "hasMore": true
 }
}

// Why cursor beats offset:
// OFFSET: SELECT * FROM orders LIMIT 20 OFFSET 10000
// - Reads and discards 10K rows to return 20
// - Page N gets slower as N grows
// - New items inserted can cause duplicates/skips

// CURSOR: SELECT * FROM orders WHERE id < ? ORDER BY id DESC LIMIT 20
// - Uses index, O(log n) always
// - Consistent even as data changes
// - The nextCursor encodes {id: lastSeenId}

// Encode cursor safely:
function encodeCursor(data) {
 return Buffer.from(JSON.stringify(data)).toString('base64');
}
function decodeCursor(cursor) {
 return JSON.parse(Buffer.from(cursor, 'base64').toString());
}

Idempotency keys — safe retries for mutations

// Clients should be able to safely retry any request
// Problem: network timeout — did the payment go through?
// Solution: idempotency key in header — same key = same result

// Client sends:
POST /v1/payments
Idempotency-Key: client-generated-uuid-here
Content-Type: application/json
{ "amount": 9999, "currency": "USD", "cardToken": "tok_xxx" }

// Server logic:
async function createPayment(req) {
 const idempotencyKey = req.headers['idempotency-key'];
 if (!idempotencyKey) throw new Error('Missing Idempotency-Key');

 // Check if we've seen this key
 const existing = await redis.get('idempotency:' + idempotencyKey);
 if (existing) return JSON.parse(existing); // Return cached response

 // Process payment
 const result = await processPayment(req.body);

 // Cache result for 24 hours
 await redis.setex('idempotency:' + idempotencyKey, 86400, JSON.stringify(result));
 return result;
}
// Now client can retry on network failure safely — same response returned

Rate limiting headers

# Every response should include rate limit context:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1712534400
Retry-After: 60 # Only on 429

# On 429 Too Many Requests:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712534400
{ "type": ".../rate-limit-exceeded", "title": "Rate Limit Exceeded",
 "detail": "You have exceeded 1000 requests per hour" }

OpenAPI spec-first workflow

# Write the spec BEFORE writing code:
# 1. Define endpoints, request/response schemas in openapi.yaml
# 2. Review with API consumers (frontend, mobile, partners)
# 3. Generate server stubs: openapi-generator
# 4. Generate client SDKs: same tool
# 5. Run contract tests: Schemathesis, Dredd

# openapi.yaml excerpt:
paths:
 /v1/users/{id}:
 get:
 operationId: getUser
 parameters:
 - name: id
 in: path
 required: true
 schema: { type: string, format: uuid }
 responses:
 '200':
 content:
 application/json:
 schema: { $ref: '#/components/schemas/User' }
 '404':
 content:
 application/json:
 schema: { $ref: '#/components/schemas/ProblemDetails' }

# Breaking change detection in CI:
# oasdiff changelog old-spec.yaml new-spec.yaml
# Fails build if breaking changes detected (removed fields, changed types)

REST API design checklist

  • ✅ URL versioning (/v1/) from day one — you will need it
  • ✅ RFC 7807 error format — consistent, tool-friendly
  • ✅ Cursor-based pagination — scalable and consistent
  • ✅ Idempotency keys on all mutating endpoints
  • ✅ Rate limit headers on every response
  • ✅ OpenAPI spec-first — define before implementing
  • ✅ Location header on 201 Created responses
  • ❌ Never use verbs in resource URLs (use nouns)
  • ❌ Never return 200 for errors
  • ❌ Never break backward compatibility without versioning

The idempotency patterns here connect to the rate limiter system design guide — both use Redis for stateful request tracking. For the infrastructure serving these APIs, Lambda cold start optimization ensures the first request to your API responds as fast as subsequent ones. External reference: OpenAPI specification guide.

Related: GraphQL vs REST: A Practical Comparison for Production APIs in 2025 · API Security Best Practices: How to Secure Your APIs Against Real-World Attacks · How to Design a Global Media Delivery and Personalization Platform on AWS with CloudFront, S3, API Gateway, and DynamoDB Global Tables

Related: Regular Expressions Mastery: Patterns That Actually Make Sense · Express.js Best Practices: Build Production-Ready APIs With Node.js · DynamoDB Single-Table Design: The Pattern That Scales to Billions of Items

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.