MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

REST API Design Best Practices for Production

Design robust, scalable REST APIs with proper status codes, versioning, pagination, and error handling.

APIRESTBackend

By MinhVo

Introduction

REST APIs power virtually every modern web and mobile application. From simple CRUD endpoints serving a React frontend to complex microservice architectures handling millions of requests per second, the design decisions you make early on determine whether your API becomes a joy to work with or a constant source of frustration. Poor API design leads to breaking changes, confused consumers, and scalability nightmares that require costly rewrites.

In this comprehensive guide, we'll explore the principles and patterns that separate production-grade REST APIs from quick prototypes. You'll learn how to structure endpoints intuitively, implement proper versioning strategies, handle errors consistently, paginate large datasets efficiently, and secure your API against common vulnerabilities. Whether you're building your first API or refactoring an existing one, these practices will help you create an interface that developers love to consume.

REST API architecture overview

Understanding REST: Core Concepts and Constraints

REST (Representational State Transfer) isn't a protocol but an architectural style defined by Roy Fielding in his doctoral dissertation. Understanding its constraints is essential for designing APIs that scale gracefully and remain maintainable over time.

The six REST constraints shape every design decision. Statelessness means each request contains all information the server needs—no session data stored between calls. This constraint enables horizontal scaling since any server instance can handle any request. The client-server separation ensures the UI and data storage evolve independently. Cacheability allows responses to be marked as cacheable, reducing server load and improving latency for consumers.

Uniform interface is the constraint that most directly affects your URL structure and HTTP method usage. Resources are identified by URIs, manipulated through representations (typically JSON), and operations map to HTTP methods: GET for retrieval, POST for creation, PUT for full replacement, PATCH for partial updates, and DELETE for removal. Layered system means intermediaries like load balancers and CDNs can sit between client and server without the client knowing. Finally, code on demand (the only optional constraint) allows servers to send executable code to extend client functionality.

The maturity model proposed by Leonard Richardson provides a useful lens for evaluating API design. Level 0 uses HTTP as a transport tunnel (like XML-RPC). Level 1 introduces resources with individual URIs. Level 2 properly uses HTTP methods and status codes. Level 3 adds hypermedia controls (HATEOAS), enabling discoverability. Most production APIs operate comfortably at Level 2, with selective adoption of Level 3 patterns where they add genuine value.

Understanding these foundations prevents common mistakes like tunneling everything through POST, returning 200 status codes for every response, or designing endpoints that require clients to construct URLs manually instead of following links.

API design principles diagram

Endpoint Structure and Resource Design

The foundation of a well-designed REST API is a consistent, intuitive URL structure. Resources should be nouns, not verbs. The HTTP method already conveys the action, so your URLs describe the data.

Naming Conventions

Use plural nouns for collections and nest resources to express relationships:

GET    /api/v1/users              # List users
GET    /api/v1/users/123          # Get specific user
POST   /api/v1/users              # Create user
PUT    /api/v1/users/123          # Replace user
PATCH  /api/v1/users/123          # Partial update
DELETE /api/v1/users/123          # Delete user
GET    /api/v1/users/123/orders   # List user's orders
GET    /api/v1/users/123/orders/456  # Specific order for user

Avoid deep nesting beyond two or three levels. Instead of /api/v1/users/123/orders/456/items/789, use /api/v1/order-items/789 with a filter: /api/v1/order-items?order_id=456. Deep nesting creates tight coupling and makes URL routing unnecessarily complex.

Query Parameters for Filtering, Sorting, and Fields

Reserve query parameters for operations that modify the response without changing the resource identity:

GET /api/v1/products?category=electronics&min_price=100
GET /api/v1/products?sort=-created_at,name
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?status=shipped&page=2&per_page=25

Consistent parameter naming across your entire API reduces the learning curve. Decide once whether you use snake_case or camelCase for query parameters and stick with it everywhere.

HTTP Status Codes

Status codes communicate the outcome without requiring the client to parse the response body. Here's a practical mapping for common scenarios:

ScenarioStatus CodeWhen to Use
Successful retrieval200 OKGET requests returning data
Successful creation201 CreatedPOST that creates a resource
Successful deletion204 No ContentDELETE with no response body
Validation failure422 Unprocessable EntityRequest format correct but data invalid
Authentication required401 UnauthorizedMissing or invalid credentials
Insufficient permissions403 ForbiddenValid credentials but lacking permission
Resource not found404 Not FoundID doesn't match any resource
Rate limit exceeded429 Too Many RequestsClient sending too many requests
Server error500 Internal Server ErrorUnhandled exceptions

Never return 200 for errors with an error field in the body. This breaks HTTP conventions and forces every client to inspect response bodies for error conditions.

Step-by-Step Implementation

Let's build a production-ready REST API with Express and TypeScript that implements all these best practices:

// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
 
interface AppError extends Error {
  statusCode: number;
  code: string;
  details?: Record<string, unknown>;
}
 
export class ApiError extends Error implements AppError {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'ApiError';
  }
 
  static badRequest(message: string, details?: Record<string, unknown>) {
    return new ApiError(400, 'BAD_REQUEST', message, details);
  }
 
  static notFound(resource: string) {
    return new ApiError(404, 'NOT_FOUND', `${resource} not found`);
  }
 
  static unprocessable(message: string, details?: Record<string, unknown>) {
    return new ApiError(422, 'VALIDATION_ERROR', message, details);
  }
}
 
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        requestId: req.headers['x-request-id'],
      },
    });
  }
 
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId: req.headers['x-request-id'],
    },
  });
}
// src/routes/users.ts
import { Router, Request, Response, NextFunction } from 'express';
import { ApiError } from '../middleware/error-handler';
 
const router = Router();
 
function validateUser(req: Request, res: Response, next: NextFunction) {
  const { email, name } = req.body;
  const errors: string[] = [];
 
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('Valid email is required');
  }
  if (!name || name.length < 2 || name.length > 100) {
    errors.push('Name must be between 2 and 100 characters');
  }
 
  if (errors.length > 0) {
    throw ApiError.unprocessable('Validation failed', { errors });
  }
  next();
}
 
router.get('/', async (req: Request, res: Response) => {
  const page = Math.max(1, parseInt(req.query.page as string) || 1);
  const perPage = Math.min(100, Math.max(1, parseInt(req.query.per_page as string) || 20));
  const sort = (req.query.sort as string) || '-created_at';
  const [sortField, sortOrder] = sort.startsWith('-')
    ? [sort.slice(1), 'DESC']
    : [sort, 'ASC'];
 
  const { rows, count } = await db.users.findAndCountAll({
    where: buildFilters(req.query),
    order: [[sortField, sortOrder]],
    limit: perPage,
    offset: (page - 1) * perPage,
  });
 
  res.json({
    data: rows.map(user => serializeUser(user)),
    meta: {
      page,
      per_page: perPage,
      total: count,
      total_pages: Math.ceil(count / perPage),
    },
    links: buildPaginationLinks(req, page, perPage, count),
  });
});
 
router.post('/', validateUser, async (req: Request, res: Response) => {
  const existing = await db.users.findOne({ where: { email: req.body.email } });
  if (existing) {
    throw ApiError.badRequest('Email already registered');
  }
 
  const user = await db.users.create({
    name: req.body.name,
    email: req.body.email,
  });
 
  res
    .status(201)
    .header('Location', `/api/v1/users/${user.id}`)
    .json({ data: serializeUser(user) });
});
 
function buildPaginationLinks(req: Request, page: number, perPage: number, total: number) {
  const baseUrl = `${req.protocol}://${req.get('host')}${req.path}`;
  const totalPages = Math.ceil(total / perPage);
  const links: Record<string, string> = {
    self: `${baseUrl}?page=${page}&per_page=${perPage}`,
    first: `${baseUrl}?page=1&per_page=${perPage}`,
    last: `${baseUrl}?page=${totalPages}&per_page=${perPage}`,
  };
  if (page > 1) links.prev = `${baseUrl}?page=${page - 1}&per_page=${perPage}`;
  if (page < totalPages) links.next = `${baseUrl}?page=${page + 1}&per_page=${perPage}`;
  return links;
}
 
export default router;
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { errorHandler } from './middleware/error-handler';
import userRoutes from './routes/users';
 
const app = express();
 
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' }));
 
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
});
app.use(limiter);
 
app.use(express.json({ limit: '10kb' }));
 
app.use((req, res, next) => {
  req.headers['x-request-id'] = req.headers['x-request-id'] || crypto.randomUUID();
  res.setHeader('X-Request-Id', req.headers['x-request-id']);
  next();
});
 
app.use('/api/v1/users', userRoutes);
 
app.use(errorHandler);
 
export default app;

This implementation demonstrates consistent error handling, input validation with detailed error messages, pagination with link headers, rate limiting, security headers, and request tracing—all essential for production readiness.

API implementation workflow

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Platform Migration

A mid-size retailer migrated from a monolithic PHP application to a REST API serving a React SPA and mobile apps. The key challenge was maintaining backward compatibility while the mobile team adopted endpoints at different paces. They implemented header-based versioning (Accept-Version: 2024-01) alongside URL versioning for major breaking changes. Pagination with cursor-based tokens prevented the "page drift" problem where items inserted between page loads caused duplicates or skipped records. The API handled 2,000 requests per second at peak holiday traffic with sub-100ms p99 latency.

Use Case 2: Healthcare Data Integration

A healthcare platform needed to expose patient data through a REST API while complying with HIPAA. Every endpoint required OAuth 2.0 with specific scopes (patient:read, patient:write). The API logged every access attempt for audit trails. They used HTTP 403 (not 404) when a user lacked permission to a resource that existed, preventing information leakage through status code differences. Rate limits were applied per-client and per-patient to prevent bulk data extraction.

Use Case 3: IoT Fleet Management

A logistics company built a REST API to manage 50,000 connected vehicles. Devices sent telemetry via POST requests every 30 seconds. The API used 202 Accepted with a polling endpoint for asynchronous processing of batch telemetry uploads. They implemented client-directed pagination with cursor tokens and If-None-Match ETags for devices that polled configuration changes, reducing bandwidth by 60% since most polls returned 304 Not Modified.

Use Case 4: Multi-Tenant SaaS Platform

A B2B SaaS product needed tenant isolation at the API layer. Every request was scoped to a tenant via a JWT claim, and the API automatically filtered all queries by tenant_id. Cross-tenant access returned 404 rather than 403 to prevent tenant enumeration. The API used RFC 7807 Problem Details format for errors, making it easy for tenant-specific frontend instances to display localized error messages.

Best Practices for Production

  1. Use RFC 7807 Problem Details for errors: Standardize error responses with type, title, status, detail, and instance fields. This format is machine-parseable and supported by many client libraries, reducing the effort consumers need to handle errors programmatically.

  2. Implement cursor-based pagination for large datasets: Offset pagination breaks when data changes between requests. Cursor pagination using a stable sort column (like created_at with a tiebreaker id) guarantees consistent results even when new records are inserted concurrently.

  3. Version your API from day one: Even if you start at v1, embed the version in the URL path (/api/v1/) or use content negotiation (Accept: application/vnd.myapi.v1+json). You will need to make breaking changes eventually, and retrofitting versioning is painful.

  4. Return the full resource representation after mutations: After POST, PUT, or PATCH, return the updated resource in the response body along with the appropriate status code. This saves the client a follow-up GET request and ensures they have the canonical representation including server-generated fields like id and created_at.

  5. Implement idempotency for non-GET requests: Add an Idempotency-Key header for POST requests that might be retried (payment processing, order creation). Store the key with the response for 24-48 hours and replay the stored response for duplicate keys, preventing accidental double-charges or duplicate records.

  6. Use ETags and conditional requests: Return an ETag header with GET responses and support If-None-Match for conditional GETs and If-Match for conditional updates. This prevents lost updates in concurrent scenarios and reduces bandwidth for frequently-polled resources.

  7. Document your API with OpenAPI/Swagger: Generate an OpenAPI specification and serve interactive documentation (like Swagger UI or Redoc). Auto-generate client SDKs from the spec for your most common client platforms. Keep the documentation in version control alongside the code.

  8. Implement proper rate limiting with headers: Return RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers following the IETF draft standard. This lets clients implement backoff logic automatically instead of blindly retrying and getting blocked.

Common Pitfalls and Solutions

PitfallImpactSolution
Using POST for everythingBreaks HTTP semantics, prevents caching, confuses consumersMap operations to appropriate HTTP methods: GET for reads, POST for creates, PUT/PATCH for updates, DELETE for removal
Returning 200 for all responsesClients can't distinguish success from failure without parsing bodyUse proper status codes: 4xx for client errors, 5xx for server errors. Reserve 200 for successful operations
No pagination on list endpointsUnbounded result sets cause OOM errors and slow responsesDefault to 20-50 items per page, enforce a max (100), return pagination metadata with links
Inconsistent naming conventionsDevelopers waste time guessing field names across endpointsChoose one style (snake_case or camelCase) and apply it uniformly to URLs, query params, and JSON fields
Exposing internal IDs and database structureSecurity risk through enumeration attacksUse UUIDs instead of sequential integers, avoid exposing table/field names in error messages
Missing CORS headersFrontend can't call the API from a different domainConfigure CORS middleware with explicit allowed origins, methods, and headers—never use * in production

Performance Optimization

Production APIs must handle traffic efficiently. Here are key optimization strategies with implementation code:

import compression from 'compression';
import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
app.use(compression({
  level: 6,
  threshold: 1024,
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  },
}));
 
async function cacheMiddleware(ttl: number = 300) {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (req.method !== 'GET') return next();
    const key = `cache:${req.originalUrl}`;
    const cached = await redis.get(key);
    if (cached) {
      res.setHeader('X-Cache', 'HIT');
      return res.json(JSON.parse(cached));
    }
    const originalJson = res.json.bind(res);
    res.json = (body: any) => {
      redis.setex(key, ttl, JSON.stringify(body));
      res.setHeader('X-Cache', 'MISS');
      return originalJson(body);
    };
    next();
  };
}
 
app.use('/api/v1/products', cacheMiddleware(600), productRoutes);

Database query optimization is equally critical. Use select to return only requested fields, implement database-level pagination with keyset pagination using a composite cursor, and add composite indexes for common filter/sort combinations. Connection pooling prevents the overhead of establishing new database connections per request. Configure pool size based on your database's max_connections divided by your application server count, with a safety margin.

Comparison with Alternatives

FeatureRESTGraphQLgRPC
Data fetchingFixed structure per endpointClient specifies exact fieldsDefined by protobuf schema
Over-fetchingCommon—returns full resourceEliminated—only requested fieldsDepends on message design
Under-fetchingRequires multiple requestsSingle request for related dataSingle request per service
CachingHTTP caching (CDN, browser)Complex—requires persisted queriesLimited—needs custom caching
ToolingMature, universalGood but requires learning curveStrong for internal services
Learning curveLowMediumMedium-High
Best forPublic APIs, CRUD appsComplex interconnected dataMicroservice-to-microservice

REST remains the best choice for public APIs where simplicity, cacheability, and broad client support matter. GraphQL excels when clients have diverse data requirements. gRPC shines for internal service communication where performance and strong typing are priorities.

Advanced Patterns and Techniques

HATEOAS for Discoverable APIs

Hypermedia links make your API self-documenting for clients:

function serializeOrder(order: Order) {
  return {
    id: order.id,
    status: order.status,
    total: order.total,
    _links: {
      self: { href: `/api/v1/orders/${order.id}` },
      customer: { href: `/api/v1/customers/${order.customerId}` },
      items: { href: `/api/v1/orders/${order.id}/items` },
      ...(order.status === 'pending' && {
        cancel: { href: `/api/v1/orders/${order.id}/cancel`, method: 'POST' },
      }),
      ...(order.status === 'shipped' && {
        track: { href: `/api/v1/orders/${order.id}/tracking` },
      }),
    },
  };
}

Bulk Operations for Batch Processing

For batch processing, create dedicated endpoints that accept arrays:

router.post('/bulk', async (req: Request, res: Response) => {
  const { operations } = req.body;
  const results = await Promise.allSettled(
    operations.map(async (op: BulkOperation) => {
      switch (op.method) {
        case 'POST': return createUser(op.data);
        case 'PATCH': return updateUser(op.id, op.data);
        case 'DELETE': return deleteUser(op.id);
        default: throw new Error(`Unsupported method: ${op.method}`);
      }
    })
  );
 
  res.status(200).json({
    results: results.map((r, i) => ({
      status: r.status === 'fulfilled' ? 'success' : 'error',
      data: r.status === 'fulfilled' ? r.value : undefined,
      error: r.status === 'rejected' ? r.reason.message : undefined,
    })),
  });
});

Testing Strategies

Comprehensive API testing covers unit tests for individual handlers, integration tests for the full request/response cycle, and contract tests to ensure backward compatibility:

import request from 'supertest';
import app from '../src/app';
 
describe('Users API', () => {
  describe('POST /api/v1/users', () => {
    it('creates a user and returns 201 with Location header', async () => {
      const res = await request(app)
        .post('/api/v1/users')
        .send({ name: 'Jane Doe', email: 'jane@example.com' })
        .expect(201);
 
      expect(res.body.data).toMatchObject({
        name: 'Jane Doe',
        email: 'jane@example.com',
      });
      expect(res.body.data.id).toBeDefined();
      expect(res.headers.location).toBe(`/api/v1/users/${res.body.data.id}`);
    });
 
    it('returns 422 for invalid email', async () => {
      const res = await request(app)
        .post('/api/v1/users')
        .send({ name: 'Jane', email: 'invalid' })
        .expect(422);
 
      expect(res.body.error.code).toBe('VALIDATION_ERROR');
    });
  });
 
  describe('GET /api/v1/users', () => {
    it('returns paginated results with meta and links', async () => {
      const res = await request(app)
        .get('/api/v1/users?page=1&per_page=5')
        .expect(200);
 
      expect(res.body.data.length).toBeLessThanOrEqual(5);
      expect(res.body.meta).toMatchObject({
        page: 1,
        per_page: 5,
        total: expect.any(Number),
      });
      expect(res.body.links.self).toBeDefined();
    });
  });
});

Use contract testing tools like Pact to verify that API changes don't break existing consumers. Generate API tests from your OpenAPI specification using tools like Schemathesis for property-based testing that discovers edge cases.

Future Outlook

REST APIs continue evolving alongside newer paradigms. The adoption of RFC 9457 (Problem Details for HTTP APIs) is standardizing error formats across the industry. AsyncAPI is bringing specification-driven development to event-driven APIs. HTTP/3 and QUIC protocols promise reduced latency for API calls on mobile networks.

The API-first design philosophy—where the API specification is written before implementation—is becoming the default approach. Tools like Stoplight and Redocly make collaborative API design accessible to entire teams. Meanwhile, the rise of AI coding assistants means well-documented, consistently-designed APIs become even more valuable as they're easier for both humans and AI tools to understand and consume.

Edge computing is pushing API logic closer to users, with frameworks like Cloudflare Workers and Deno Deploy enabling globally distributed API endpoints with single-digit millisecond cold starts.

Conclusion

Designing production-ready REST APIs requires attention to consistency, error handling, pagination, security, and performance from the very first endpoint. The practices covered in this guide form the foundation that separates APIs developers love from APIs developers tolerate.

Key takeaways for your next API project:

  1. Start with an OpenAPI specification and design the contract before writing implementation code
  2. Use plural nouns for resources, proper HTTP methods for operations, and meaningful status codes for outcomes
  3. Implement pagination, filtering, and sorting consistently across all list endpoints from day one
  4. Standardize error responses with RFC 7807 Problem Details and include request IDs for debugging
  5. Add rate limiting, CORS, input validation, and authentication as middleware before your first endpoint goes live
  6. Build comprehensive test suites covering happy paths, validation errors, and edge cases
  7. Document everything with OpenAPI and serve interactive documentation for consumers

The investment you make in API design pays dividends throughout the lifetime of your product. A well-designed API reduces support tickets, accelerates client development, and gracefully accommodates the inevitable changes that real-world applications demand.

For deeper exploration, consult the RESTful Web APIs specification by Leonard Richardson, the JSON:API specification for standardized REST responses, and the IETF HTTP API RFCs for emerging standards.