REST API Design Guide: Patterns for Production APIs
Service: SEO-Optimized Blog Post | Price: $15 | Format: dev.to-ready | Category: Backend
A well-designed REST API is the foundation of every great web application. It reduces integration time, prevents bugs, and scales with your team.
This guide covers battle-tested patterns for building REST APIs that developers love to use.
1. Resource Naming Conventions
Use Nouns, Not Verbs
✅ GET /users — List all users
✅ GET /users/:id — Get one user
✅ POST /users — Create a user
✅ PATCH /users/:id — Update a user
✅ DELETE /users/:id — Delete a user
❌ GET /getUsers
❌ POST /createUser
❌ POST /deleteUser
Plural Nouns for Collections
✅ GET /users
✅ GET /users/:id/orders
✅ GET /users/:id/orders/:orderId
❌ GET /user — Singular is inconsistent
❌ GET /user/list — Verb in URL
2. Request Validation with Zod
import { z } from "zod";
// Define your schema once
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "user", "viewer"]).default("user"),
});
// Type inference from schema
type CreateUserInput = z.infer<typeof createUserSchema>;
// Express middleware
function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
// Usage
router.post("/users", validate(createUserSchema), createUserHandler);
3. Error Response Format
Consistent Error Envelope
interface ApiError {
error: string; // Machine-readable error code
message: string; // Human-readable description
details?: Record<string, string[]>; // Field-level errors
requestId?: string; // Correlation ID for debugging
}
Standard HTTP Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | GET, PATCH succeeded |
| 201 | Created | POST succeeded |
| 204 | No Content | DELETE succeeded |
| 400 | Bad Request | Malformed input |
| 401 | Unauthorized | Missing/invalid auth |
| 403 | Forbidden | Valid auth, insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, stale version |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
4. Pagination
Cursor-Based Pagination (Recommended)
interface PaginatedResponse<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
// Request
GET /api/users?cursor=abc123&limit=20
// Response
{
"data": [{ "id": "user_456", "name": "Alice" }],
"nextCursor": "def456",
"hasMore": true
}
Offset Pagination (Simple Cases)
interface OffsetPaginatedResponse<T> {
data: T[];
page: number;
limit: number;
total: number;
totalPages: number;
}
5. API Versioning
URL Path Versioning
const router = Router();
// v1 routes
router.get("/v1/users", v1UserHandler);
// v2 routes
router.get("/v2/users", v2UserHandler);
Content Negotiation
router.get("/users", (req, res) => {
if (req.headers["accept"]?.includes("application/vnd.api.v2+json")) {
return v2UserHandler(req, res);
}
return v1UserHandler(req, res);
});
6. Authentication & Authorization
Bearer Token Pattern
function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "unauthorized" });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: "invalid_token" });
}
}
7. Rate Limiting
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: {
error: "rate_limit_exceeded",
message: "Too many requests. Please try again later.",
},
});
router.use("/api", limiter);
Key Takeaways
- Nouns, not verbs — Resources map to nouns, HTTP methods map to actions
- Validate early — Zod schemas catch errors before they reach your business logic
- Consistent errors — Every error has the same envelope format
- Cursor pagination — Scales better than offset for large datasets
- Version early — URL path versioning is simplest to implement and maintain
- Always authenticate — Every endpoint needs auth, even if public routes are whitelisted
This post is part of the Production DevOps Patterns series. Follow for more backend, API, and infrastructure best practices.
Publish-ready: Copy this markdown directly to dev.to, Medium, or your blog. Frontmatter included.













