API Design: REST Best Practices
Design APIs that developers love. Practical guidance on resource naming, status codes, pagination, versioning, error responses, and common pitfalls.
A bad API is forever. Once external clients depend on your endpoints, every mistake is cemented in place. Bad naming, wrong status codes, inconsistent pagination — they all become “features” you can’t change without breaking someone.
Let’s design APIs that make sense the first time, with practical conventions that scale.
Resource Naming
Resources are nouns, not verbs. The HTTP method provides the verb.
# ❌ Verbs in URLs — treating HTTP like RPC
POST /createUser
GET /getUser?id=123
POST /updateUser
DELETE /deleteUser?id=123
POST /sendEmail
# ✅ Nouns — let the HTTP method do the work
POST /users → Create a user
GET /users/123 → Get user 123
PUT /users/123 → Replace user 123
PATCH /users/123 → Partially update user 123
DELETE /users/123 → Delete user 123
POST /emails → Send an email (create an email resource)
Naming Conventions
# Use plural nouns
GET /users ✅
GET /user ❌
# Use kebab-case for multi-word resources
GET /order-items ✅
GET /orderItems ❌
GET /order_items ❌
# Nest for relationships (max 2 levels deep)
GET /users/123/orders ✅ Orders belonging to user 123
GET /users/123/orders/456 ✅ Specific order of a specific user
GET /users/123/orders/456/items/789 ❌ Too deep — flatten it
# Better: flatten deep nesting
GET /order-items/789 ✅ Direct access by ID
GET /orders/456/items ✅ Items of an order (only 2 levels)
Actions That Don’t Fit CRUD
Some operations aren’t simple CRUD. Use sub-resources or verbs in specific cases:
# State transitions — use a sub-resource
POST /orders/123/cancel → Cancel an order
POST /users/123/activate → Activate a user account
POST /invoices/456/send → Send an invoice
# Batch operations — use a dedicated endpoint
POST /users/batch → Create multiple users
POST /emails/bulk-send → Send multiple emails
# Search — use query parameters on the collection
GET /users?role=admin&status=active
GET /products?search=keyboard&minPrice=50&maxPrice=200
Status Codes: Mean What You Say
Use the right status code. Clients make decisions based on these.
Success Codes
// 200 OK — Request succeeded, here's the data
app.get("/users/:id", async (req, res) => {
const user = await userRepo.findById(req.params.id);
res.status(200).json(user);
});
// 201 Created — New resource created, here it is
app.post("/users", async (req, res) => {
const user = await userRepo.create(req.body);
res.status(201)
.header("Location", `/users/${user.id}`)
.json(user);
});
// 204 No Content — Success, nothing to return
app.delete("/users/:id", async (req, res) => {
await userRepo.delete(req.params.id);
res.status(204).send();
});
Client Error Codes
// 400 Bad Request — Malformed input (validation failed)
// "I can't understand what you sent me"
res.status(400).json({
error: "validation_error",
message: "Invalid request body",
details: [
{ field: "email", message: "Must be a valid email address" },
{ field: "age", message: "Must be a positive integer" },
],
});
// 401 Unauthorized — "Who are you?" (no valid credentials)
res.status(401).json({
error: "unauthorized",
message: "Authentication required",
});
// 403 Forbidden — "I know who you are, but you can't do this"
res.status(403).json({
error: "forbidden",
message: "You don't have permission to delete this resource",
});
// 404 Not Found — Resource doesn't exist
res.status(404).json({
error: "not_found",
message: "User not found",
});
// 409 Conflict — Can't do this because of current state
// (e.g., trying to create a user with an email that already exists)
res.status(409).json({
error: "conflict",
message: "A user with this email already exists",
});
// 422 Unprocessable Entity — I understood the format, but the content is wrong
// (e.g., trying to transfer more money than available)
res.status(422).json({
error: "unprocessable",
message: "Insufficient funds for this transfer",
});
Server Error Codes
// 500 Internal Server Error — Something broke on our side
// Don't leak internal details to clients!
res.status(500).json({
error: "internal_error",
message: "An unexpected error occurred",
requestId: correlationId, // Include this for debugging
});
// 503 Service Unavailable — Temporarily down (maintenance, overload)
res.status(503)
.header("Retry-After", "60")
.json({
error: "service_unavailable",
message: "Service is temporarily unavailable. Please retry.",
});
Common Status Code Mistakes
❌ 200 for everything (including errors with error messages in the body)
❌ 404 for authentication failures (that's 401)
❌ 500 for invalid input (that's 400 or 422)
❌ 200 with { "success": false } — use proper status codes!
❌ 403 when the resource doesn't exist (information leakage — use 404)
Error Responses: Be Consistent
Define one error format and use it everywhere:
interface ApiError {
error: string; // Machine-readable error code
message: string; // Human-readable description
details?: ErrorDetail[]; // Field-level validation errors
requestId?: string; // For support/debugging
}
interface ErrorDetail {
field: string;
message: string;
code?: string; // e.g., "too_short", "invalid_format"
}
// Every error response follows this format:
{
"error": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Must be a valid email", "code": "invalid_format" },
{ "field": "name", "message": "Must be at least 2 characters", "code": "too_short" }
],
"requestId": "req-a1b2c3d4"
}
Pagination
Never return unbounded lists. Always paginate collections.
Cursor-Based Pagination (Recommended)
// Request:
// GET /users?limit=20&after=eyJpZCI6MTAwfQ
// Response:
{
"data": [
{ "id": 101, "name": "Alice" },
{ "id": 102, "name": "Bob" },
// ... 18 more
],
"pagination": {
"limit": 20,
"hasMore": true,
"nextCursor": "eyJpZCI6MTIwfQ",
"prevCursor": "eyJpZCI6MTAxfQ"
}
}
Cursor-based pagination is stable (inserting/deleting items doesn’t shift pages) and performant (uses indexed lookups, not OFFSET).
app.get("/users", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.after
? JSON.parse(Buffer.from(req.query.after as string, "base64").toString())
: null;
const users = await db.query(
`SELECT * FROM users WHERE ($1::uuid IS NULL OR id > $1) ORDER BY id LIMIT $2`,
[cursor?.id ?? null, limit + 1] // Fetch one extra to check hasMore
);
const hasMore = users.length > limit;
const data = users.slice(0, limit);
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString("base64")
: null;
res.json({
data,
pagination: { limit, hasMore, nextCursor },
});
});
Offset-Based Pagination (Simpler but Fragile)
GET /users?page=3&perPage=20
{
"data": [...],
"pagination": {
"page": 3,
"perPage": 20,
"total": 1543,
"totalPages": 78
}
}
Simpler to implement but suffers from: skipped/duplicated items when data changes between pages, and slow OFFSET queries on large tables.
Versioning
APIs change. Plan for it.
URL Path Versioning (Most Common)
GET /v1/users/123
GET /v2/users/123
Simple, explicit, easy to route. The downside: it’s in every URL, and bumping a version for one endpoint means versioning everything.
Header Versioning
GET /users/123
Accept: application/vnd.myapp.v2+json
Cleaner URLs, but harder to test in a browser and less discoverable.
Pick one approach and stick with it. URL versioning wins for simplicity in most cases.
When to Version
- Breaking changes (removing fields, changing types, renaming endpoints) → New version
- Additive changes (new optional fields, new endpoints) → Same version
- Bug fixes → Same version
Filtering, Sorting, and Searching
Use query parameters consistently:
# Filtering
GET /orders?status=pending&minTotal=100
# Sorting
GET /users?sort=createdAt&order=desc
GET /users?sort=-createdAt # Alternative: prefix with - for descending
# Searching
GET /products?search=mechanical+keyboard
# Field selection (sparse fieldsets)
GET /users/123?fields=id,name,email
Python Example: A Clean FastAPI Endpoint
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
app = FastAPI()
class UserResponse(BaseModel):
id: str
name: str
email: str
class PaginatedResponse(BaseModel):
data: list[UserResponse]
has_more: bool
next_cursor: str | None
@app.get("/users", response_model=PaginatedResponse)
async def list_users(
limit: int = Query(default=20, ge=1, le=100),
after: str | None = Query(default=None),
role: str | None = Query(default=None),
sort: str = Query(default="created_at"),
):
users = await user_repo.find(
after_cursor=after,
limit=limit + 1,
role=role,
sort_by=sort,
)
has_more = len(users) > limit
data = users[:limit]
return PaginatedResponse(
data=[UserResponse.model_validate(u) for u in data],
has_more=has_more,
next_cursor=encode_cursor(data[-1].id) if has_more else None,
)
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: str):
user = await user_repo.find_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse.model_validate(user)
Common Mistakes
- Using POST for everything — GET for reads, POST for creates, PUT/PATCH for updates, DELETE for deletes
- Returning 200 with error bodies — Use proper status codes
- Inconsistent naming — Pick a convention (camelCase vs snake_case for JSON fields) and enforce it
- No pagination — Every list endpoint should paginate from day one
- Leaking internal details — Don’t expose database IDs, stack traces, or internal service names in error messages
- Not documenting — Use OpenAPI/Swagger. Generate it from code if possible.
A well-designed API is a product. Treat it like one — with clear naming, consistent behavior, and documentation that developers actually want to read. The time you invest in getting it right upfront saves everyone time forever.