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.
