REST API Design Best Practices: The Complete Guide for 2026
A well-designed REST API is a joy to work with. A poorly designed one is a nightmare that costs your team hours every week. The difference isn't about technology — it's about following conventions that make your API intuitive, consistent, and predictable.
This guide covers the rules that every API designer should follow, with examples of what to do and what to avoid.
URL Structure
Use Nouns, Not Verbs
# BAD: Verbs in URLs
POST /createUser
GET /getUserById/123
POST /updateUser/123
GET /deleteUser/123
# GOOD: Nouns + HTTP methods
POST /users # Create
GET /users/123 # Read
PUT /users/123 # Update (full)
PATCH /users/123 # Update (partial)
DELETE /users/123 # Delete
The HTTP method is the verb. The URL identifies the resource.
Use Plural Nouns
# BAD
GET /user/123
GET /user
# GOOD
GET /users/123
GET /users
Consistency: always plural. /users, /orders, /products, /posts.
Nested Resources for Relationships
# User's posts
GET /users/123/posts
# Specific post by a user
GET /users/123/posts/456
# Post's comments
GET /posts/456/comments
# But DON'T nest too deep
# BAD: /users/123/posts/456/comments/789/replies/012
# GOOD: /comments/789/replies (flatten after one level)
Rule of thumb: Maximum one level of nesting. /users/123/posts is fine. /users/123/posts/456/comments/789 is not.
Use Query Parameters for Filtering, Sorting, Pagination
# Filter
GET /products?category=electronics&brand=apple
GET /products?price_min=100&price_max=500
# Sort
GET /products?sort=-created_at # Descending by created_at
GET /products?sort=name # Ascending by name
GET /products?sort=-price,name # Multi-sort
# Paginate
GET /products?page=1&limit=20
GET /products?cursor=abc123&limit=20 # Cursor-based (better for large datasets)
# Select fields
GET /products?fields=id,name,price
# Search
GET /products?q=wireless+headphones
# Expand related resources
GET /orders/123?include=user,products
HTTP Methods
| Method | Purpose | Safe | Idempotent | |--------|---------|------|------------| | GET | Read resource | Yes | Yes | | POST | Create resource | No | No | | PUT | Replace resource (full) | No | Yes | | PATCH | Modify resource (partial) | No | No* | | DELETE | Remove resource | No | Yes | | HEAD | Check existence | Yes | Yes | | OPTIONS | Available methods | Yes | Yes |
- Safe: Doesn't modify data
- Idempotent: Repeating gives same result
- *PATCH is technically not idempotent, but should be designed to be
POST vs PUT vs PATCH
# POST: Create new resource (server assigns ID)
POST /users
Body: { "name": "John", "email": "john@example.com" }
Response: 201 Created, { "id": 123, "name": "John", ... }
# PUT: Replace entire resource (client provides all fields)
PUT /users/123
Body: { "name": "John", "email": "john@newemail.com", "age": 31 }
Response: 200 OK, { "id": 123, ... }
# PATCH: Partial update (only changed fields)
PATCH /users/123
Body: { "email": "john@newemail.com" }
Response: 200 OK, { "id": 123, "email": "john@newemail.com", ... }
HTTP Status Codes
2xx Success
| Code | Meaning | When to Use | |------|---------|-------------| | 200 OK | Success | GET, PUT, PATCH, DELETE success | | 201 Created | Resource created | POST that creates new resource | | 202 Accepted | Request queued | Long-running operations | | 204 No Content | Success, no body | DELETE success |
3xx Redirection
| Code | Meaning | When to Use | |------|---------|-------------| | 301 Moved Permanently | URL changed | Resource moved to new URL | | 304 Not Modified | Use cache | Conditional GET with ETag |
4xx Client Errors
| Code | Meaning | When to Use | |------|---------|-------------| | 400 Bad Request | Invalid input | Malformed JSON, validation errors | | 401 Unauthorized | Not authenticated | Missing/invalid token | | 403 Forbidden | Not authorized | Valid auth but no permission | | 404 Not Found | Resource doesn't exist | Invalid ID | | 409 Conflict | Duplicate resource | Email already exists | | 422 Unprocessable Entity | Validation failed | Valid JSON but business rule violation | | 429 Too Many Requests | Rate limited | Exceeded API limits |
5xx Server Errors
| Code | Meaning | When to Use | |------|---------|-------------| | 500 Internal Server Error | Unhandled error | Unexpected server error | | 502 Bad Gateway | Upstream failed | Database down | | 503 Service Unavailable | Server overloaded | Maintenance mode | | 504 Gateway Timeout | Upstream timeout | External API timeout |
The Big Mistake: Using 200 for Errors
// BAD: HTTP 200 with error body
HTTP 200 OK
{
"success": false,
"error": "User not found"
}
// GOOD: Proper HTTP status codes
HTTP 404 Not Found
{
"error": "User not found",
"code": "USER_NOT_FOUND"
}
HTTP status codes exist for a reason. Use them correctly.
Response Format
Consistent Envelope
// Single resource
{
"data": {
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
}
// Collection
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"total": 100,
"page": 1,
"limit": 20,
"total_pages": 5
}
}
// Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": [
{ "field": "email", "message": "Email is required" }
]
}
}
Use ISO 8601 Dates
// GOOD
{
"created_at": "2026-07-05T14:30:00Z",
"updated_at": "2026-07-05T15:45:00+08:00"
}
// BAD
{
"created_at": "07/05/2026",
"updated_at": 1751723400
}
ISO 8601 is unambiguous, machine-readable, and supports timezones.
Lowercase Field Names with Underscores
// Pick one and be consistent
{
"first_name": "John", // snake_case (Python/Ruby convention)
"created_at": "2026-..."
}
// Or
{
"firstName": "John", // camelCase (JavaScript convention)
"createdAt": "2026-..."
}
The important thing is consistency. Don't mix firstName and last_name.
Pagination
Page-Based (Simple)
GET /products?page=3&limit=20
{
"data": [...],
"meta": {
"page": 3,
"limit": 20,
"total": 500,
"total_pages": 25
}
}
Cursor-Based (Scalable)
GET /products?cursor=eyJpZCI6MTAwfQ&limit=20
{
"data": [...],
"meta": {
"limit": 20,
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}
}
Use cursor-based for large datasets. Page-based gets slow on page 10,000 because the database must skip 200,000 rows. Cursor-based uses a WHERE clause that's always fast.
Authentication
Bearer Token (JWT)
# Client sends token in Authorization header
GET /users/123
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# Server validates token and identifies user
API Keys (for Service-to-Service)
GET /api/v1/users
X-API-Key: your-api-key-here
OAuth 2.0 (for Third-Party Access)
# Authorization code flow
GET /auth/callback?code=abc123
# Exchange code for access token
POST /auth/token
grant_type=authorization_code
code=abc123
client_id=...
client_secret=...
Versioning
URL Versioning (Most Common)
GET /api/v1/users
GET /api/v2/users
Simple, explicit, and easy to route. The downside: URL changes between versions.
Header Versioning
GET /users
Accept: application/vnd.api+json;version=2
Cleaner URLs but harder to test in a browser.
When to Version
Breaking changes require a new version:
- Removing a field
- Changing a field type
- Changing URL structure
- Changing authentication mechanism
Non-breaking changes DON'T require versioning:
- Adding new fields
- Adding new endpoints
- Adding optional parameters
Rate Limiting
Response Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 942
X-RateLimit-Reset: 1720000000
When Rate Limited
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 60 seconds."
}
}
CORS (Cross-Origin Resource Sharing)
# Required headers for browser-accessible APIs
Access-Control-Allow-Origin: https://yourapp.com # Specific origin (not *)
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
Access-Control-Allow-Credentials: true
Handle preflight OPTIONS requests:
OPTIONS /users
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type
# Response
HTTP/1.1 204 No Content
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Error Handling Best Practices
Structured Error Responses
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format"
},
{
"field": "password",
"message": "Password must be at least 12 characters"
}
],
"documentation_url": "https://docs.example.com/api/errors/validation"
}
}
Use Meaningful Error Codes
// Don't do this
{ "error": "Something went wrong" }
// Do this
{
"error": {
"code": "INSUFFICIENT_STOCK",
"message": "Cannot add 5 units of 'Wireless Headphones' to cart. Only 3 available.",
"details": {
"product_id": 42,
"requested": 5,
"available": 3
}
}
}
API Documentation
OpenAPI/Swagger
Use OpenAPI 3.1 to document your API:
openapi: 3.1.0
info:
title: Blog API
version: 1.0.0
paths:
/posts:
get:
summary: List all posts
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Post'
Tools like Swagger UI, Redoc, and Stoplight render this into interactive documentation.
Checklist: REST API Audit
- [ ] URLs use plural nouns
- [ ] HTTP methods are used correctly (GET never modifies data)
- [ ] Appropriate HTTP status codes (not 200 for everything)
- [ ] Consistent JSON structure across all endpoints
- [ ] Pagination on all collection endpoints
- [ ] Rate limiting implemented
- [ ] Authentication required (except public endpoints)
- [ ] CORS configured correctly
- [ ] Input validation on all endpoints
- [ ] Meaningful error messages with error codes
- [ ] API versioned (v1)
- [ ] Documentation (OpenAPI/Swagger)
- [ ] HTTPS enforced
- [ ] No sensitive data in URLs (use headers/body)
- [ ] Consistent field naming (snake_case or camelCase, not both)
Conclusion
Good API design is invisible — developers use your API without thinking about it. Bad API design is constantly in the way — every endpoint has surprises, responses are inconsistent, and integration takes twice as long as expected.
The rules in this guide aren't arbitrary. They're conventions that the entire web development ecosystem follows. Following them means your API works the way developers expect, integrates smoothly with any HTTP client, and stands the test of time.
Design your API as if you were going to be the one consuming it. Because eventually, you will be.