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.