Skip to content

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.

3 min read
HTTP request and response flow diagram showing REST API design patterns

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.

tstypescript
// ❌ 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.

tstypescript
// ❌ 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.

tstypescript
// 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.

tstypescript
// ❌ 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:

StrategyURL exampleProsCons
URL path/api/v2/usersObvious, easy to routeDuplicates routes
HeaderAccept: application/vnd.api+json;version=2Clean URLsHidden, harder to test
tstypescript
// 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 differently

URL-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.

tstypescript
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

  1. Nest sub-resources one level deep — promote deeper nesting to top-level with filters
  2. Use cursor-based pagination for stable results under concurrent writes
  3. Standardize error responses with machine-readable codes, not just messages
  4. Version from day one — URL-based versioning is simplest for most teams
  5. Communicate rate limits through headers so clients can self-throttle
  6. Filter and sort through query parameters with a consistent, predictable pattern
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX