正在加载,请稍候…

REST API Design Best Practices: URL Structure, Versioning, and Error Handling

Learn REST API design best practices used by top tech companies. Covers URL naming conventions, HTTP methods, versioning strategies, error responses, authentication, and documentation.

REST API Design Best Practices: URL Structure, Versioning, and Error Handling

A well-designed REST API is a joy to use. A poorly designed one becomes a maintenance nightmare. These best practices come from patterns used by Stripe, GitHub, Twilio, and other developer-loved APIs.

URL Design Principles

Use Nouns, Not Verbs

❌ Bad — verbs in URLs
GET  /getUsers
POST /createUser
PUT  /updateUser/123
GET  /deleteUser/123

✅ Good — nouns, HTTP method is the verb
GET    /users           (list users)
POST   /users           (create user)
GET    /users/123       (get user 123)
PUT    /users/123       (replace user 123)
PATCH  /users/123       (partial update user 123)
DELETE /users/123       (delete user 123)

Plural Nouns for Collections

/users        not /user
/products     not /product
/orders       not /order
/categories   not /category

Nested Resources for Relationships

/users/123/orders           Orders belonging to user 123
/users/123/orders/456       Order 456 of user 123
/posts/789/comments         Comments on post 789
/posts/789/comments/101     Comment 101 on post 789

But keep nesting shallow (max 2-3 levels):
❌ /users/123/orders/456/items/789/reviews    Too deep!
✅ /order-items/789/reviews                  Flatten it

Use Query Parameters for Filtering, Sorting, Pagination

GET /products?category=electronics&inStock=true
GET /users?sort=createdAt&order=desc
GET /orders?status=pending&page=2&limit=20
GET /products?minPrice=10&maxPrice=100&search=wireless

HTTP Methods — Use Them Correctly

Method Purpose Idempotent? Body?
GET Retrieve data Yes No
POST Create resource No Yes
PUT Replace resource (full) Yes Yes
PATCH Update resource (partial) No Yes
DELETE Remove resource Yes Sometimes

PUT vs PATCH

// PUT — replace entire resource (must send ALL fields)
PUT /users/123
{
  "name": "Alice Johnson",
  "email": "alice@example.com",
  "role": "admin",
  "phone": "+1-555-0100"
}

// PATCH — update only specified fields
PATCH /users/123
{
  "phone": "+1-555-9999"
}

API Versioning

Option 1: URL Path (Recommended for Public APIs)

GET /v1/users
GET /v2/users    (new version with breaking changes)
// Express versioning
app.use('/v1', v1Router);
app.use('/v2', v2Router);

// v1Router
v1Router.get('/users', getUsersV1);

// v2Router — v2 might have different response format
v2Router.get('/users', getUsersV2);

Option 2: Accept Header (More REST-pure)

GET /users
Accept: application/vnd.myapi.v2+json

Option 3: Custom Header

GET /users
API-Version: 2

Recommendation: URL versioning is the most explicit and easiest to test in browsers. Use it for public APIs.

Response Format Consistency

// ✅ Single resource
GET /users/123
{
  "data": {
    "id": "123",
    "type": "user",
    "attributes": {
      "name": "Alice Johnson",
      "email": "alice@example.com",
      "createdAt": "2026-01-15T10:30:00Z"
    }
  }
}

// ✅ Collection
GET /users?page=1&limit=20
{
  "data": [
    { "id": "123", "name": "Alice Johnson", "email": "alice@example.com" },
    { "id": "124", "name": "Bob Smith", "email": "bob@example.com" }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 847,
    "totalPages": 43
  },
  "links": {
    "self": "/users?page=1&limit=20",
    "next": "/users?page=2&limit=20",
    "last": "/users?page=43&limit=20"
  }
}

Error Handling

Consistent Error Format

// ✅ Stripe-style error response
{
  "error": {
    "type": "validation_error",
    "message": "Invalid request parameters",
    "code": "VALIDATION_FAILED",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "code": "INVALID_FORMAT"
      },
      {
        "field": "age",
        "message": "Must be at least 18",
        "code": "MIN_VALUE"
      }
    ],
    "requestId": "req_abc123"
  }
}

HTTP Status Codes

// Express error handler example
export function errorHandler(err, req, res, next) {
  // 4xx — Client errors
  if (err instanceof ValidationError) {
    return res.status(422).json({
      error: { type: 'validation_error', message: err.message, details: err.details }
    });
  }
  
  if (err instanceof NotFoundError) {
    return res.status(404).json({
      error: { type: 'not_found', message: err.message, code: 'RESOURCE_NOT_FOUND' }
    });
  }
  
  if (err instanceof UnauthorizedError) {
    return res.status(401).json({
      error: { type: 'authentication_error', message: 'Invalid or expired token' }
    });
  }
  
  if (err instanceof ForbiddenError) {
    return res.status(403).json({
      error: { type: 'authorization_error', message: 'Insufficient permissions' }
    });
  }
  
  if (err.code === '23505') { // PostgreSQL unique violation
    return res.status(409).json({
      error: { type: 'conflict', message: 'Resource already exists' }
    });
  }
  
  // 5xx — Server errors
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: {
      type: 'server_error',
      message: 'An unexpected error occurred',
      requestId: req.id, // For debugging
    }
  });
}

Authentication

Bearer Token (JWT) Pattern

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

// In Express:
const token = req.headers.authorization?.replace('Bearer ', '');

API Key Pattern (for service-to-service)

X-API-Key: sk_live_abc123...
// OR as query param (less secure, avoid for sensitive operations):
GET /data?api_key=sk_live_abc123

Filtering and Search

// Filter by exact value
GET /products?category=electronics

// Filter by range
GET /products?minPrice=10&maxPrice=500
GET /orders?startDate=2026-01-01&endDate=2026-03-31

// Full text search
GET /articles?q=javascript+async+await

// Multiple values (comma-separated or repeated param)
GET /products?category=electronics,clothing
GET /products?category=electronics&category=clothing

// Nested field filter
GET /users?address.city=SanFrancisco

API Documentation with OpenAPI/Swagger

# openapi.yaml
openapi: 3.0.3
info:
  title: My API
  version: 1.0.0

paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Users list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'

Common API Design Mistakes

Mistake Fix
GET /deleteUser/123 DELETE /users/123
Returning 200 for errors Use proper 4xx/5xx
No pagination Add page/limit params
Inconsistent field names (camelCase vs snake_case) Pick one, stick to it
No versioning Add /v1/ prefix from the start
Returning passwords in responses Never include sensitive fields
PUT when you mean PATCH Understand the difference

Checklist Before Shipping

  • URL uses nouns, not verbs
  • Consistent camelCase (or snake_case) throughout
  • Pagination on all list endpoints
  • Consistent error response format
  • API version in URL (/v1/)
  • Authentication documented
  • Rate limiting headers in responses
  • OpenAPI/Swagger documentation generated

→ Parse and inspect URLs with the URL Parser tool.