How to Design a REST API: Best Practices Guide
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.
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
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
}
}
}
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"
}
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.