DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

REST API Design: URLs, Status Codes, Pagination, and the Decisions That Actually Matter

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But don't nest deeper than 2 levels — it becomes unwieldy:

# This is too deep
GET /users/123/posts/456/comments/789/likes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

For server-to-server: API keys.

Authorization: ApiKey sk_live_abc123
# or
X-API-Key: sk_live_abc123
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)