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.