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 withtype,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
- ✅
Locationheader 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.
Recommended Books
→ Designing Data-Intensive Applications — The essential deep-dive on distributed systems, databases, and production engineering at scale.
→ The Pragmatic Programmer — Timeless principles for writing better code, debugging smarter, and advancing as an engineer.
Affiliate links. We earn a small commission at no extra cost to you.
Free Weekly Newsletter
🚀 Don’t Miss the Next Cheat Code
You just read something most developers never learn. Get more secrets like this delivered every week — JavaScript internals, Python optimizations, AWS architectures, system design, and AI workflows.
Join 1,000+ senior developers who actually level up. Zero fluff, pure signal.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
