REST API design has a lot of folklore ("always use nouns", "HATEOAS", "REST vs GraphQL") and a lot of pragmatics that matter more. This guide covers the decisions that actually affect how easy your API is to use and maintain.
URL Design
Use nouns, not verbs. The HTTP method provides the verb:
# Bad
POST /getUser
POST /createUser
POST /deleteUser
# Good
GET /users/{id}
POST /users
DELETE /users/{id}
Plural nouns for collections, singular for specific resources... though consistency matters more than the plural/singular choice. Pick one and stick to it:
GET /users # list
GET /users/123 # single user
POST /users # create
PUT /users/123 # full update
PATCH /users/123 # partial update
DELETE /users/123 # delete
Nested Resources
Use nesting for resources that only exist in the context of a parent:
GET /users/123/posts # all posts by user 123
POST /users/123/posts # create post for user 123
GET /users/123/posts/456 # specific post
But don't nest deeper than 2 levels — it becomes unwieldy:
# This is too deep
GET /users/123/posts/456/comments/789/likes
For deep nesting, break the URL at a natural point:
# Better: use the resource directly with a filter
GET /comments/789/likes
GET /comments?postId=456
HTTP Methods and Status Codes
Use the right method:
-
GET: read, no side effects, cacheable -
POST: create, submit data -
PUT: replace entire resource -
PATCH: partial update -
DELETE: delete
Return the right status codes:
| Code | When |
|---|---|
| 200 | Success with body |
| 201 | Resource created (with Location header) |
| 204 | Success, no body (DELETE, some PATCHes) |
| 400 | Bad request (validation error, malformed JSON) |
| 401 | Not authenticated |
| 403 | Authenticated but not authorized |
| 404 | Resource not found |
| 409 | Conflict (duplicate, version mismatch) |
| 422 | Unprocessable entity (semantic validation failure) |
| 429 | Rate limit exceeded |
| 500 | Server error (don't expose details in response) |
The 401 vs 403 distinction matters: 401 means "please authenticate", 403 means "you're authenticated but can't do this". Returning 401 for permission errors confuses clients.
Request and Response Format
Use JSON. Set Content-Type:
Content-Type: application/json
Accept: application/json
Response Structure
Be consistent. A simple flat structure is easier to consume than envelope-heavy patterns:
// Single resource
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
// Collection
{
"data": [
{ "id": 123, "name": "Alice" },
{ "id": 124, "name": "Bob" }
],
"pagination": {
"total": 150,
"page": 1,
"perPage": 20,
"nextCursor": "eyJpZCI6MTI0fQ"
}
}
// Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
}
}
Dates
Always use ISO 8601 in UTC. Never Unix timestamps (ambiguous, hard to read) or localized date strings:
{
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T14:22:33Z"
}
Filtering, Sorting, Pagination
Use query parameters:
GET /articles?status=published&tag=firefox
GET /articles?sort=publishedAt&order=desc
GET /articles?page=2&perPage=20
GET /articles?cursor=eyJpZCI6MTI0fQ # cursor-based
Cursor-based pagination is better than offset for large datasets:
- Offset pagination (
page=3&perPage=20) breaks when items are added/deleted between requests - Cursor pagination uses a stable reference point (usually the last seen ID)
// Cursor-based pagination
const cursor = Buffer.from(JSON.stringify({ id: lastId })).toString('base64');
// Client passes: GET /articles?cursor=eyJpZCI6MTI0fQ&limit=20
// Server decodes and uses as WHERE id > lastId
Versioning
Version your API from day one. Don't wait until you need to break something:
# URL versioning (most common)
GET /v1/users
GET /v2/users
# Header versioning
Accept: application/vnd.myapi.v2+json
URL versioning is more explicit and easier to debug. Header versioning is "more correct" by REST semantics but harder for newcomers to discover.
Maintain backwards compatibility within a version. Don't change field names or remove fields in a patch — bump the version.
Authentication
For most APIs: JWT Bearer tokens.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
For server-to-server: API keys.
Authorization: ApiKey sk_live_abc123
# or
X-API-Key: sk_live_abc123
Never put API keys or tokens in query strings — they appear in server logs.
Rate Limiting
Always rate limit. Return 429 Too Many Requests with retry guidance:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705318200
Rate limit headers help clients back off intelligently instead of hammering your server.
Error Handling
Return machine-readable error codes alongside human-readable messages:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "No user with ID 999 exists",
"requestId": "req_abc123"
}
}
The code field lets clients handle specific errors programmatically. The message is for humans (logs, developer UIs). Include a requestId for production debugging.
Don't expose stack traces, SQL errors, or internal implementation details in API responses.
Idempotency
GET, PUT, and DELETE should be idempotent — calling them multiple times has the same effect as calling once.
For POST requests that should be idempotent (payment processing, sending emails), use an idempotency key:
POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
{
"amount": 1000,
"currency": "USD"
}
Store the key and return the same response for duplicate requests. Stripe uses this pattern.
Documentation
Document with OpenAPI/Swagger. Tools like Swagger UI auto-generate interactive documentation from your spec:
openapi: 3.0.0
info:
title: My API
version: 1.0.0
paths:
/users/{id}:
get:
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
The spec becomes the contract between frontend and backend. Generate client SDKs from it with openapi-generator.
Checklist
Before shipping an API:
- [ ] Consistent URL naming convention
- [ ] Correct HTTP methods and status codes
- [ ] ISO 8601 dates in UTC
- [ ] Error responses include machine-readable codes
- [ ] Authentication required where appropriate
- [ ] Rate limiting implemented
- [ ] Input validation with 400/422 on bad input
- [ ] No internal details in error responses
- [ ] API versioned from day one
- [ ] OpenAPI spec written or generated
Good API design is mostly consistency. The specific choices (plural vs singular, cursor vs offset pagination) matter less than applying them uniformly across your entire API surface.
Top comments (0)