REST API Design: Beyond the Basics
Once you know the HTTP verbs, the real challenge begins — pagination, filtering, error formats, and versioning patterns that make APIs a pleasure to consume.

Every REST tutorial covers the same ground: use nouns for resources, HTTP verbs for actions, and return proper status codes. That's table stakes. The hard part starts when real clients need to paginate through thousands of records, filter by complex criteria, and handle errors gracefully across different API versions.
Resource Naming That Scales
URLs should describe resources, not operations. But the real nuance is deciding where one resource ends and another begins.
// ❌ Verbs in URLs — REST anti-pattern
app.post("/api/createUser", handler);
app.get("/api/getUserOrders", handler);
app.put("/api/updateOrderStatus", handler);
// ✅ Resources and sub-resources
app.post("/api/users", createUser);
app.get("/api/users/:userId/orders", getUserOrders);
app.patch("/api/orders/:orderId", updateOrder);Nest sub-resources only one level deep. Beyond that, promote the nested resource to a top-level resource with a filter parameter. /api/users/:userId/orders/:orderId/items/:itemId is too deep — use /api/order-items?orderId=abc instead.
Pagination Done Right
Offset-based pagination is simple but breaks under concurrent writes. Cursor-based pagination is stable and performant.
// ❌ Offset pagination — skips or duplicates records when data changes
// GET /api/orders?page=3&limit=20
// If an order is inserted while user is on page 2, page 3 shows a duplicate
// ✅ Cursor-based pagination — stable regardless of mutations
// GET /api/orders?cursor=eyJpZCI6MTAwfQ&limit=20
interface PaginatedResponse<T> {
data: T[];
pagination: {
nextCursor: string | null;
hasMore: boolean;
limit: number;
};
}
function paginateOrders(cursor: string | null, limit: number) {
const decodedCursor = cursor
? JSON.parse(Buffer.from(cursor, "base64url").toString())
: null;
const orders = db.orders.findMany({
where: decodedCursor ? { id: { gt: decodedCursor.id } } : undefined,
take: limit + 1,
orderBy: { id: "asc" },
});
const hasMore = orders.length > limit;
const data = hasMore ? orders.slice(0, -1) : orders;
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data.at(-1)!.id })).toString("base64url")
: null;
return { data, pagination: { nextCursor, hasMore, limit } };
}Always return a hasMore boolean. Clients should never have to guess whether there's a next page.
Filtering and Sorting
Support filtering through query parameters with a consistent pattern. Don't invent custom query languages.
// GET /api/products?category=electronics&minPrice=100&maxPrice=500&sort=-createdAt
interface ProductFilters {
category?: string;
minPrice?: number;
maxPrice?: number;
search?: string;
sort?: string;
}
function parseSort(sort: string): { field: string; direction: "asc" | "desc" } {
if (sort.startsWith("-")) {
return { field: sort.slice(1), direction: "desc" };
}
return { field: sort, direction: "asc" };
}
function buildProductQuery(filters: ProductFilters) {
const where: Record<string, unknown> = {};
if (filters.category) where.category = filters.category;
if (filters.minPrice) where.price = { gte: filters.minPrice };
if (filters.maxPrice) where.price = { ...where.price, lte: filters.maxPrice };
if (filters.search) where.name = { contains: filters.search, mode: "insensitive" };
const orderBy = filters.sort
? { [parseSort(filters.sort).field]: parseSort(filters.sort).direction }
: { createdAt: "desc" };
return { where, orderBy };
}Prefix sort fields with - for descending. Allow comma-separated values for multi-sort: ?sort=-price,name.
Consistent Error Responses
Every error from your API should follow the same structure. Clients should handle errors with a single parser, not per-endpoint logic.
// ❌ Inconsistent errors — different shapes per endpoint
// { error: "Not found" }
// { message: "Validation failed", errors: [...] }
// { status: "error", reason: "Unauthorized" }
// ✅ Single error format across the entire API
interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
function errorResponse(
status: number,
code: string,
message: string,
details?: Record<string, string[]>,
): Response {
return Response.json(
{ error: { code, message, details } },
{ status },
);
}
// Usage
errorResponse(404, "ORDER_NOT_FOUND", "Order abc-123 does not exist");
errorResponse(422, "VALIDATION_ERROR", "Request body is invalid", {
email: ["must be a valid email address"],
quantity: ["must be greater than 0"],
});Use machine-readable error codes (ORDER_NOT_FOUND), not just messages. Codes let clients branch logic; messages are for human debugging.
API Versioning Strategy
Version your API from day one. The two practical approaches:
| Strategy | URL example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v2/users | Obvious, easy to route | Duplicates routes |
| Header | Accept: application/vnd.api+json;version=2 | Clean URLs | Hidden, harder to test |
// URL-based versioning — explicit and simple
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// Shared logic lives in services, not route handlers
// v1 and v2 route handlers call the same service layer
// but shape the response differentlyURL-based versioning wins for most teams. It's immediately visible, cacheable by CDNs, and testable with curl.
Rate Limiting Headers
Communicate rate limits through standard headers so clients can self-throttle before hitting the wall.
function rateLimitHeaders(
limit: number,
remaining: number,
resetAt: Date,
): Record<string, string> {
return {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(Math.floor(resetAt.getTime() / 1000)),
"Retry-After": String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)),
};
}When a client is rate-limited, return 429 Too Many Requests with the Retry-After header. Well-behaved clients use this to back off automatically.
Key Takeaways
- Nest sub-resources one level deep — promote deeper nesting to top-level with filters
- Use cursor-based pagination for stable results under concurrent writes
- Standardize error responses with machine-readable codes, not just messages
- Version from day one — URL-based versioning is simplest for most teams
- Communicate rate limits through headers so clients can self-throttle
- Filter and sort through query parameters with a consistent, predictable pattern


