Why API Design Matters More Than You Think
Your API is a product. Every naming inconsistency, cryptic error code, and missing pagination cursor adds friction for every developer who integrates with it. Google's API Design Guide runs 80+ pages because great API design is hard, nuanced work.
Here are the patterns that will make your API a pleasure to use.
Resource Naming — Nouns, Not Verbs
# ❌ BAD — RPC-style, not REST
POST /getUser
POST /createNewBlogPost
GET /deleteComment?id=5
# ✅ GOOD — Resource-oriented
GET /users/{id}
POST /posts
DELETE /posts/{postId}/comments/{commentId}URL Structure Rules
- Lowercase with hyphens for multi-word resources:
/blog-posts,/user-profiles - Plural nouns for collections:
/users,/posts,/comments - Nested resources for relationships:
/posts/{postId}/comments - Limit nesting depth to 2 levels:
/posts/{id}/comments✅ vs/users/{uid}/posts/{pid}/comments/{cid}/replies❌
HTTP Verb Semantics
| Verb | Semantics | Body | Idempotent? |
|---|---|---|---|
| GET | Fetch resource(s) | No | Yes |
| POST | Create new resource | Yes | No |
| PUT | Replace entire resource | Yes | Yes |
| PATCH | Partially update resource | Yes | No |
| DELETE | Remove resource | No | Yes |
Response Format Design
Consistent Success Envelope
// GET /posts?page=2&limit=10
{
"data": [
{ "id": "post_abc123", "title": "...", "excerpt": "..." },
{ "id": "post_def456", "title": "...", "excerpt": "..." }
],
"meta": {
"page": 2,
"limit": 10,
"total": 847,
"totalPages": 85
},
"links": {
"self": "/posts?page=2&limit=10",
"next": "/posts?page=3&limit=10",
"prev": "/posts?page=1&limit=10",
"first": "/posts?page=1&limit=10",
"last": "/posts?page=85&limit=10"
}
}Error Format — RFC 7807 (Problem Details)
// 422 Unprocessable Entity
{
"type": "https://api.bipinbaral.com.np/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid fields",
"instance": "/posts",
"errors": [
{
"field": "title",
"code": "TOO_SHORT",
"message": "Title must be at least 5 characters",
"received": "Hi"
},
{
"field": "category",
"code": "INVALID_VALUE",
"message": "Category must be one of: tech, devops, ai, security",
"received": "random-stuff"
}
]
}This format (RFC 7807) is the industry standard. Clients know exactly what field failed, with what code, and why.
API Versioning Strategies
Strategy 1: URI Versioning (Most Common)
/api/v1/posts
/api/v2/posts ← Breaking changes go herePros: Explicit, easy to understand, cacheable.
Cons: URL pollution, clients must update URLs.
Strategy 2: Header Versioning
GET /api/posts
API-Version: 2026-08-01Pros: Clean URLs, more REST-ful.
Cons: Harder to test in browser, requires proper caching headers.
Strategy 3: Content Negotiation
Accept: application/vnd.bipinapi.v2+jsonUsed by GitHub's API. Most precise but most complex.
Recommendation: URI versioning for public APIs, header versioning for internal services.
Rate Limiting Implementation
// Using express-rate-limit + Redis
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
// Different limits for different endpoints
export const standardRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: "draft-7",
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
keyGenerator: (req) => req.user?.id ?? req.ip, // Per-user limiting for authenticated
handler: (req, res) => {
res.status(429).json({
type: "https://api.example.com/problems/rate-limit-exceeded",
title: "Rate Limit Exceeded",
status: 429,
detail: "Too many requests. Please wait before retrying.",
retryAfter: res.getHeader("Retry-After"),
});
},
});
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login attempts per 15 minutes
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
});Rate Limit Response Headers
Always return these headers so clients can implement backoff:
RateLimit-Limit: 100
RateLimit-Remaining: 43
RateLimit-Reset: 1724234400 ← Unix timestamp when window resets
Retry-After: 847 ← Seconds to wait (only on 429)Cursor-Based Pagination
For large datasets, offset pagination degrades performance:
// Cursor pagination — efficient even for millions of rows
app.get("/api/posts", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 10, 100);
const cursor = req.query.cursor as string | undefined;
let query = db
.selectFrom("posts")
.select(["id", "title", "excerpt", "published_at"])
.where("status", "=", "published")
.orderBy("published_at", "desc")
.orderBy("id", "desc") // Tiebreaker for stable sort
.limit(limit + 1); // Fetch 1 extra to know if there's a next page
if (cursor) {
const { date, id } = decodeCursor(cursor);
query = query.where((eb) =>
eb.or([
eb("published_at", "<", date),
eb.and([eb("published_at", "=", date), eb("id", "<", id)]),
])
);
}
const rows = await query.execute();
const hasNextPage = rows.length > limit;
const items = hasNextPage ? rows.slice(0, limit) : rows;
const nextCursor =
hasNextPage && items.length > 0
? encodeCursor({ date: items.at(-1)!.published_at, id: items.at(-1)!.id })
: null;
res.json({
data: items,
meta: { limit, hasNextPage },
links: {
next: nextCursor ? `/api/posts?cursor=${nextCursor}&limit=${limit}` : null,
},
});
});OpenAPI 3.1 Documentation
Auto-generate beautiful interactive docs:
// With NestJS + @nestjs/swagger
import { ApiProperty, ApiResponse, ApiOperation } from "@nestjs/swagger";
export class CreatePostDto {
@ApiProperty({ example: "My Tech Article", minLength: 5, maxLength: 200 })
title: string;
@ApiProperty({ example: "tech", enum: ["tech", "devops", "ai", "security"] })
category: string;
}
@Controller("posts")
@ApiTags("Posts")
export class PostsController {
@Post()
@ApiOperation({ summary: "Create a new blog post" })
@ApiResponse({ status: 201, description: "Post created successfully", type: PostDto })
@ApiResponse({ status: 422, description: "Validation failed", type: ProblemDetailDto })
create(@Body() dto: CreatePostDto) {
return this.postsService.create(dto);
}
}Result: auto-generated Swagger UI at /api/docs with try-it-live functionality.
Conclusion
A great REST API is like a great library — it does exactly what you expect, fails clearly when it can't, and stays consistent throughout. Invest in your error format, pagination pattern, and versioning strategy early. These decisions are expensive to change after clients are in production.


