How to Design a REST API: Best Practices Guide

How to Design a REST API: Best Practices Guide

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

How to Design a REST API: Best Practices

REST API design fails when developers focus on HTTP verbs and status codes while ignoring the client's actual workflow. You end up with technically correct endpoints—proper GET/POST usage, semantic URLs, appropriate status codes—that require ten API calls to accomplish what should be one operation, return megabytes of data the client never uses, or force clients to maintain complex state synchronization logic because the API exposes database tables instead of business operations.

This article covers REST API design practices that create maintainable, efficient APIs. You'll learn how to design resource models that match client needs rather than database schemas, structure endpoints that minimize round trips while staying RESTful, handle errors in ways that enable meaningful client responses, version APIs without breaking existing clients, and implement pagination, filtering, and sorting that scale to production data volumes. These patterns come from APIs serving millions of requests daily.

We'll cover resource design principles, URL structure conventions, HTTP method semantics, error handling strategies, pagination and filtering patterns, authentication and authorization approaches, and versioning strategies that balance stability and evolution.

Resource Design: Think in Nouns, Not Database Tables

The fundamental REST principle: resources are nouns (users, orders, products), not verbs (getUser, createOrder). But the deeper principle that many APIs miss: resources should represent domain concepts from the client's perspective, not your database schema. Your API is a contract with clients, not a projection of your implementation.

Consider an e-commerce API. Your database might have tables for orders, order_items, shipping_addresses, billing_addresses, payments, and inventory_reservations. Exposing all these as separate resources creates a nightmare for clients: creating an order requires POST to /orders, then POST to /order-items for each item, then POST to /payments, then POST to /inventory-reservations. Five requests to accomplish one business operation.

Better approach: design resources around business operations. A single POST to /orders with a request body containing items, shipping address, and payment method creates the order atomically. The API handles coordination across database tables. Clients work with cohesive business concepts.

Key Insight: Your API is a user interface for developers. Design it around their tasks and workflows, not your database schema. The best REST APIs feel like domain-specific languages that express business operations naturally.

Choosing Resource Granularity

Too fine-grained: clients make many requests to accomplish tasks, increasing latency and complexity. Too coarse-grained: responses contain data clients don't need, wasting bandwidth. The balance point: resources should represent coherent business entities that clients commonly need together.

// Too fine-grained: separate resources for related data
GET /users/123
GET /users/123/profile
GET /users/123/preferences
GET /users/123/subscription

// Better: composite resource with reasonable defaults
GET /users/123
{
  "id": "123",
  "email": "[email protected]",
  "profile": {
    "name": "John Doe",
    "avatar": "https://..."
  },
  "preferences": {
    "emailNotifications": true,
    "theme": "dark"
  },
  "subscription": {
    "plan": "pro",
    "expiresAt": "2026-12-31"
  }
}

// For clients that only need basic info, support field selection
GET /users/123?fields=id,email,profile.name
{
  "id": "123",
  "email": "[email protected]",
  "profile": {
    "name": "John Doe"
  }
}

Nested Resources vs Top-Level Resources

Use nested URLs when resources exist in the context of a parent: /users/123/orders makes sense because you're viewing orders for a specific user. But also provide top-level access when resources can be accessed independently: /orders/456 for accessing a specific order by ID.

Pattern When to Use Example
Nested Resources Resource belongs to parent, commonly accessed in that context GET /users/123/orders
Top-Level with Filter Resource accessed independently and by relationship GET /orders?userId=123
Both Flexibility for different client needs Support both patterns

Avoid deeply nested URLs (/users/123/orders/456/items/789/reviews). They're brittle—changing hierarchy breaks URLs—and unnecessary. /reviews/789 with proper authorization is clearer.

URL Structure and Naming Conventions

Consistency in URL structure reduces cognitive load. When developers can predict endpoint patterns, they write integration code faster and make fewer mistakes.

Core Conventions

// Use plural nouns for collections
GET /users           # List users
POST /users          # Create user
GET /users/123       # Get specific user
PUT /users/123       # Update entire user
PATCH /users/123     # Partial update
DELETE /users/123    # Delete user

// Use lowercase, hyphen-separated words
GET /user-profiles
GET /order-items

// Avoid verbs in URLs (use HTTP methods instead)
❌ POST /createUser
❌ GET /getUser/123
✅ POST /users
✅ GET /users/123

// For non-CRUD operations, use verbs as sub-resources
POST /orders/123/cancel
POST /orders/123/refund
POST /users/123/reset-password

// Version in URL or header
GET /v1/users
# or
GET /users (with Accept: application/vnd.myapi.v1+json)

Query Parameters for Operations

Use query parameters for filtering, sorting, pagination, and field selection. Keep the base URL resource-focused.

// Filtering
GET /products?category=electronics&inStock=true&minPrice=100

// Sorting
GET /products?sort=-price,name  # Sort by price descending, then name ascending

// Pagination
GET /products?page=2&limit=50
# or cursor-based
GET /products?after=eyJpZCI6MTIzfQ&limit=50

// Field selection (sparse fieldsets)
GET /users?fields=id,email,profile.name

// Include related resources
GET /orders?include=items,customer

// Search
GET /products?q=laptop&category=electronics
Pro Tip: Document your query parameter patterns in API documentation and be consistent across all endpoints. If you use "limit" for page size in one endpoint, don't use "pageSize" in another. Consistency makes APIs learnable.

HTTP Methods and Idempotency

HTTP methods have defined semantics. Following them makes your API predictable and enables HTTP caching and retry logic.

Method Semantics

Method Purpose Idempotent Safe
GET Retrieve resource Yes Yes
POST Create resource or trigger action No No
PUT Replace entire resource Yes No
PATCH Partial update Yes* No
DELETE Remove resource Yes No

*PATCH can be idempotent if designed carefully. Operations like "set email to X" are idempotent. Operations like "increment counter by 1" are not.

PUT vs PATCH

// PUT: Replace entire resource
PUT /users/123
{
  "email": "[email protected]",
  "name": "John Doe",
  "age": 30,
  "preferences": { "theme": "dark" }
}
# Server replaces the entire user object

// PATCH: Partial update
PATCH /users/123
{
  "email": "[email protected]"
}
# Server updates only the email field, leaves others unchanged

// PATCH with JSON Patch (RFC 6902) for precise operations
PATCH /users/123
Content-Type: application/json-patch+json
[
  { "op": "replace", "path": "/email", "value": "[email protected]" },
  { "op": "add", "path": "/preferences/notifications", "value": true }
]

Making POST Idempotent with Idempotency Keys

POST is not idempotent by default—repeated requests create multiple resources. For critical operations (payments, orders), support idempotency keys.

// Client includes idempotency key in header
POST /orders
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
{
  "items": [...],
  "total": 99.99
}

// Server stores the key and response
// If same key is sent again, return cached response instead of creating duplicate

// Implementation pseudocode
async function handleCreateOrder(request) {
  const idempotencyKey = request.headers['idempotency-key'];

  if (idempotencyKey) {
    const cached = await cache.get(idempotencyKey);
    if (cached) return cached.response;
  }

  const order = await createOrder(request.body);

  if (idempotencyKey) {
    await cache.set(idempotencyKey, {
      response: order,
      expiresAt: Date.now() + 24 * 60 * 60 * 1000  // 24 hours
    });
  }

  return order;
}

Status Codes and Error Handling

HTTP status codes communicate outcomes. Use them correctly and consistently. Clients should be able to handle errors programmatically based on status codes without parsing error messages.

Core Status Codes

Code Meaning When to Use
200 OK Success GET, PUT, PATCH succeeded
201 Created Resource created POST created new resource
204 No Content Success, no body DELETE succeeded, PATCH with no response
400 Bad Request Invalid input Validation failed, malformed JSON
401 Unauthorized Auth required No auth token or invalid token
403 Forbidden Not allowed Authenticated but lacking permission
404 Not Found Resource doesn't exist GET/PUT/DELETE nonexistent resource
409 Conflict Resource conflict Duplicate email, version mismatch
422 Unprocessable Business logic error Valid format but breaks business rules
429 Too Many Requests Rate limit exceeded Client exceeded rate limit
500 Internal Error Server error Unexpected server-side failure
503 Service Unavailable Temporarily down Maintenance, overload, dependency down

Error Response Format

Standardize error responses across your API. Include enough information for clients to handle errors programmatically.

// Good error response format
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Email format is invalid",
        "code": "INVALID_FORMAT"
      },
      {
        "field": "age",
        "message": "Age must be at least 18",
        "code": "MIN_VALUE_VIOLATION"
      }
    ],
    "requestId": "req_abc123",
    "timestamp": "2026-03-28T10:30:00Z"
  }
}

// For business logic errors
{
  "error": {
    "code": "INSUFFICIENT_STOCK",
    "message": "Cannot create order: insufficient stock for product SKU-123",
    "details": {
      "productId": "SKU-123",
      "requested": 10,
      "available": 3
    }
  }
}
Warning: Never expose internal error details (stack traces, database errors, file paths) in production API responses. Log them server-side, return generic 500 errors to clients. Detailed internal errors are security vulnerabilities and don't help clients anyway.

Pagination, Filtering, and Sorting

Collection endpoints must handle pagination—returning thousands of resources in one response is impractical. Choose pagination strategy based on use case.

Offset-Based Pagination

Simple and familiar but has issues with shifting data (if items are deleted between requests, pages can skip or duplicate items).

GET /products?page=2&limit=50

// Response
{
  "data": [ /* 50 products */ ],
  "pagination": {
    "page": 2,
    "limit": 50,
    "total": 1247,
    "totalPages": 25,
    "hasNext": true,
    "hasPrev": true
  }
}

Cursor-Based Pagination

More reliable for real-time data. Cursor points to a specific item, subsequent requests fetch items after that cursor.

GET /products?limit=50

// Response
{
  "data": [ /* 50 products */ ],
  "pagination": {
    "nextCursor": "eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTAzLTI4VDEwOjMwOjAwWiJ9",
    "hasMore": true
  }
}

// Next page
GET /products?limit=50&cursor=eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTAzLTI4VDEwOjMwOjAwWiJ9

Cursor is typically a base64-encoded JSON object containing the last item's ID and sort field values. Server decodes it to construct the next query.

Filtering and Sorting

// Filtering
GET /products?category=electronics&minPrice=100&maxPrice=500&inStock=true

// Sorting (+ for ascending, - for descending)
GET /products?sort=-createdAt,name

// Combined
GET /products?category=electronics&sort=-price&limit=20

// Complex filters (consider using query language like FIQL or RQL)
GET /products?filter=category==electronics;price>100;price<500

Field Selection (Sparse Fieldsets)

// Only return specific fields
GET /users?fields=id,email,profile.name

// Response
{
  "data": [
    {
      "id": "123",
      "email": "[email protected]",
      "profile": {
        "name": "John Doe"
      }
    }
  ]
}

This reduces bandwidth and improves performance by not serializing unused fields.

Authentication and Authorization

Authentication Patterns

Most modern REST APIs use token-based authentication (JWT, OAuth 2.0) rather than sessions.

// Bearer token authentication
GET /users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

// API Key (for server-to-server)
GET /data
X-API-Key: sk_live_abc123...

// OAuth 2.0 (for third-party access)
GET /user/profile
Authorization: Bearer oauth_access_token_xyz...

Authorization Patterns

Include permission information in API responses so clients know what actions are available.

// Include permissions in response
GET /orders/123
{
  "id": "123",
  "status": "pending",
  "total": 99.99,
  "permissions": {
    "canCancel": true,
    "canRefund": false,
    "canEdit": true
  }
}

// Or use HTTP headers for capabilities
GET /orders/123
Allow: GET, PATCH, DELETE
# Client knows they can GET, PATCH, DELETE but not POST

Rate Limiting

Communicate rate limits through headers so clients can back off before hitting limits.

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1648473600

// When limit exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1648473600
Retry-After: 3600

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Try again in 3600 seconds."
  }
}

API Versioning Strategies

APIs evolve. Versioning lets you make breaking changes without breaking existing clients. Choose a versioning strategy and stick with it.

URL Versioning

GET /v1/users/123
GET /v2/users/123

// Pros: Explicit, easy to route, clear in logs
// Cons: URLs change, caching complexity

Header Versioning

GET /users/123
Accept: application/vnd.myapi.v1+json

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

// Pros: URLs stable, follows HTTP semantics
// Cons: Harder to test in browser, less visible

Query Parameter Versioning

GET /users/123?version=1
GET /users/123?version=2

// Pros: Easy to test, explicit
// Cons: Pollutes query parameters, less conventional

Versioning Best Practices

Only create new versions for breaking changes. Additive changes (new fields, new endpoints) don't require versions—clients ignore unknown fields. Breaking changes include: removing fields, changing field types, changing URL structure, changing semantics of existing operations.

// Additive change (no version bump needed)
// v1 response
{
  "id": "123",
  "email": "[email protected]"
}

// v1 response with new field (backward compatible)
{
  "id": "123",
  "email": "[email protected]",
  "phoneNumber": "+1234567890"  // New field, old clients ignore it
}

// Breaking change (requires new version)
// v1 response
{
  "id": "123",
  "name": "John Doe"
}

// v2 response (broke "name" into "firstName" and "lastName")
{
  "id": "123",
  "firstName": "John",
  "lastName": "Doe"
}
Key Insight: Support old API versions for a defined period (6-12 months) but deprecate them eventually. Communicate deprecation through headers (Deprecation: true, Sunset: 2027-01-01) and documentation. Maintaining too many versions becomes unsustainable.

HATEOAS and API Discoverability

HATEOAS (Hypermedia as the Engine of Application State) means including links to related resources and available actions in responses. This makes APIs self-documenting and reduces client coupling.

GET /orders/123
{
  "id": "123",
  "status": "pending",
  "total": 99.99,
  "items": [ /* ... */ ],
  "_links": {
    "self": { "href": "/orders/123" },
    "customer": { "href": "/customers/456" },
    "cancel": { "href": "/orders/123/cancel", "method": "POST" },
    "items": { "href": "/orders/123/items" }
  }
}

// After order is shipped, cancel link disappears
GET /orders/123
{
  "id": "123",
  "status": "shipped",
  "total": 99.99,
  "_links": {
    "self": { "href": "/orders/123" },
    "customer": { "href": "/customers/456" },
    "track": { "href": "/orders/123/tracking" },
    "items": { "href": "/orders/123/items" }
  }
}

Clients can follow links rather than constructing URLs. When available actions change based on state, the links reflect that, making the API state machine explicit.

Performance Optimization

Caching with ETags

// Initial request
GET /users/123

HTTP/1.1 200 OK
ETag: "686897696a7c876b7e"
Cache-Control: max-age=3600
{ "id": "123", "email": "[email protected]" }

// Subsequent request
GET /users/123
If-None-Match: "686897696a7c876b7e"

HTTP/1.1 304 Not Modified
# No body, client uses cached version

Compression

GET /users
Accept-Encoding: gzip, deflate

HTTP/1.1 200 OK
Content-Encoding: gzip
# Response body is gzip compressed

Batch Operations

Allow creating/updating multiple resources in one request to reduce round trips.

POST /users/batch
[
  { "email": "[email protected]", "name": "User 1" },
  { "email": "[email protected]", "name": "User 2" },
  { "email": "[email protected]", "name": "User 3" }
]

// Response includes status for each
{
  "results": [
    { "status": "created", "id": "123" },
    { "status": "error", "error": "Email already exists" },
    { "status": "created", "id": "124" }
  ]
}

Frequently Asked Questions

Should I use REST or GraphQL for my API?

REST works well when you control both client and server, resources map clearly to business entities, and you value HTTP caching and simple tooling. GraphQL shines when clients have diverse needs (mobile vs web with different data requirements), you want to minimize round trips, or you're building a platform API consumed by many different clients. For most internal APIs, REST's simplicity is sufficient. For public platform APIs with many consumers, GraphQL's flexibility justifies the additional complexity.

How do I handle file uploads in REST APIs?

Use multipart/form-data for direct uploads to your API or pre-signed URLs for direct-to-storage uploads (S3, GCS). For large files, pre-signed URLs are better—clients upload directly to storage, reducing load on your API servers. Return metadata about the uploaded file as the API response.

Should I use PUT or POST for updates?

Use PUT when clients send the complete resource and you're replacing it entirely. Use PATCH for partial updates where clients send only changed fields. POST is for creating resources or triggering non-idempotent actions. In practice, many APIs use PATCH for most updates since sending the entire resource is often unnecessary.

How do I handle soft deletes in REST APIs?

Soft deletes (marking records inactive rather than physically deleting) can be implemented as PATCH requests that update a status field or dedicated endpoints like POST /users/123/deactivate. DELETE should represent permanent deletion. If you use DELETE for soft deletes, document that behavior clearly since it violates client expectations that DELETE is permanent.

What's the best way to handle long-running operations?

Return 202 Accepted immediately with a location header pointing to a status endpoint. Clients poll that endpoint to check progress. Include estimated completion time if possible. For very long operations, consider webhooks to notify clients when operations complete rather than requiring polling.

How do I version database schemas alongside API versions?

Maintain backward compatibility in your database by adding fields rather than modifying them. Use database views or transformation layers to present different schemas to different API versions. Avoid tight coupling between API version and database schema—the API is a contract with clients, the database is an implementation detail.

Should I include metadata in response bodies or headers?

Use headers for HTTP-defined metadata (caching, content type, rate limits). Use response body for domain-specific metadata (pagination, totals, timestamps). This separation keeps HTTP semantics in headers and domain semantics in the body, making both easier to understand.

How do I handle webhook security?

Sign webhook payloads with HMAC and include the signature in a header. Clients verify the signature to ensure webhooks came from your API. Include a timestamp in the payload and reject webhooks older than a threshold (5 minutes) to prevent replay attacks. Support webhook retry with exponential backoff for failed deliveries.

What's the difference between 401 and 403 status codes?

401 Unauthorized means authentication is required but wasn't provided or is invalid. Include a WWW-Authenticate header suggesting how to authenticate. 403 Forbidden means the client is authenticated but doesn't have permission for this operation. The distinction: 401 says "I don't know who you are," 403 says "I know who you are but you can't do this."

How do I design APIs for real-time data?

REST isn't ideal for real-time. Use WebSockets for bidirectional real-time communication, Server-Sent Events (SSE) for server-to-client streaming, or long polling as a fallback. Provide REST endpoints for initial state and mutations, use real-time protocols for updates. This hybrid approach works well—REST for operations, WebSockets/SSE for live updates.

Conclusion

REST API design succeeds when it prioritizes client developer experience over internal implementation details. Design resources around business concepts clients understand, not database tables. Structure URLs consistently so patterns are predictable. Use HTTP methods and status codes correctly so clients can rely on standard semantics. Handle errors informatively so clients can respond appropriately. Version carefully to balance evolution and stability.

The best REST APIs feel intuitive—developers can predict endpoint patterns, understand responses without constant documentation reference, and build integrations confidently. Achieve this through consistency (same patterns across all endpoints), clarity (explicit rather than clever), and completeness (enough information in responses to avoid additional round trips).

Start with clear resource models based on client use cases. Define URL patterns and stick to them. Implement proper error handling with informative messages. Add pagination and filtering for collection endpoints. Version the API from the start even if you only have v1. The discipline of good REST API design compounds—each well-designed endpoint makes the next one easier because patterns are established and clients trust the API's behavior.


Share on Social Media: