A well-designed API is a joy to work with. A poorly designed one becomes a maintenance nightmare. After years of building and consuming APIs, here are the patterns I reach for every time.
1. Resource-Oriented Design
Think in resources (nouns), not actions (verbs):
// Bad — verb-based
POST /api/getUserById
POST /api/createNewUser
POST /api/deleteUserAccount
// Good — resource-based
GET /api/users/:id
POST /api/users
DELETE /api/users/:id
HTTP methods already express the action. Your URL should express the resource.
2. Consistent Error Responses
Every error response should have the same shape. Clients shouldn't need to handle 10 different error formats:
interface ApiError {
error: {
code: string; // machine-readable (e.g. "USER_NOT_FOUND")
message: string; // human-readable
details?: unknown; // optional extra context
};
requestId: string; // for debugging/support
}
// Example
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "field": "email", "message": "Invalid email address" }
]
},
"requestId": "req_01HXYZ..."
}
3. Pagination — Cursor Over Offset
Offset pagination (?page=2&limit=20) breaks when data changes between requests. Cursor pagination is stable:
// Cursor-based response envelope
interface PaginatedResponse<T> {
data: T[];
pagination: {
hasMore: boolean;
nextCursor: string | null;
total?: number; // optional — expensive to compute
};
}
// Query: GET /api/posts?cursor=eyJpZCI6IjEyMyJ9&limit=20
4. Versioning from Day One
Even if you're the only consumer, version your API:
/api/v1/users
/api/v2/users ← breaking changes go here
This lets you evolve the API without breaking existing clients. I prefer URL versioning for public APIs (it's explicit) and header versioning for internal APIs.
5. Request Validation at the Boundary
Validate everything at the API boundary before it reaches your business logic. Zod makes this clean:
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10).default([]),
publishedAt: z.coerce.date().optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = CreatePostSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: { code: "VALIDATION_ERROR", message: "Invalid body", details: parsed.error.issues } },
{ status: 400 }
);
}
// parsed.data is fully typed and safe to use
const post = await createPost(parsed.data);
return Response.json(post, { status: 201 });
}
6. Idempotency for Mutations
For operations like payment or order creation, accept an Idempotency-Key header. If the same key is seen twice, return the cached result instead of processing again:
const idempotencyKey = req.headers.get("Idempotency-Key");
if (idempotencyKey) {
const cached = await cache.get(`idem:${idempotencyKey}`);
if (cached) return Response.json(cached);
}
const result = await processPayment(data);
if (idempotencyKey) {
await cache.set(`idem:${idempotencyKey}`, result, { ttl: 86400 });
}
7. Rate Limiting
Protect your API from abuse and accidental DDoS. A simple token bucket in Redis:
import { Ratelimit } from "@upstash/ratelimit";
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 req/min
});
const { success, limit, remaining } = await ratelimit.limit(userId);
if (!success) {
return Response.json(
{ error: { code: "RATE_LIMITED", message: "Too many requests" } },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
},
}
);
}
8. OpenAPI Spec
Document your API with an OpenAPI spec. Tools like zod-to-openapi let you derive the spec from your existing Zod schemas — zero duplication:
import { generateOpenApi } from "@ts-rest/open-api";
const openApiDocument = generateOpenApi(router, {
info: { title: "My API", version: "1.0.0" },
});
This gives you automatic Swagger UI, type-safe client generation, and contract testing for free.
Closing Thought
Good API design is an act of empathy — you're designing an interface for future-you and your teammates. Consistency, predictability, and clear error messages go further than any clever optimisation.