DEV Community

Devanand
Devanand

Posted on

REST API Design Guide: Patterns for Production APIs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Offset Pagination (Simple Cases)

interface OffsetPaginatedResponse<T> {
  data: T[];
  page: number;
  limit: number;
  total: number;
  totalPages: number;
}
Enter fullscreen mode Exit fullscreen mode

5. API Versioning

URL Path Versioning

const router = Router();

// v1 routes
router.get("/v1/users", v1UserHandler);

// v2 routes  
router.get("/v2/users", v2UserHandler);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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" });
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Nouns, not verbs — Resources map to nouns, HTTP methods map to actions
  2. Validate early — Zod schemas catch errors before they reach your business logic
  3. Consistent errors — Every error has the same envelope format
  4. Cursor pagination — Scales better than offset for large datasets
  5. Version early — URL path versioning is simplest to implement and maintain
  6. 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.

Top comments (0)