Designing RESTful APIs That Scale: Best Practices
A well-designed API is invisible. It works so intuitively that consumers never need to check the documentation for common operations. A poorly designed API, by contrast, generates a constant stream of support questions, integration bugs, and frustrated developers.
After building and maintaining APIs that serve hundreds of thousands of requests daily — including the real-time vendor pricing API for PeptiSync and the payment flow for Zeron.dev — I've developed strong opinions about what works in production and what doesn't. This guide captures those patterns.
Resource Naming and URL Structure
The foundation of a RESTful API is resource-oriented URLs.
Basic Conventions
- Use nouns, not verbs:
/ordersnot/getOrdersor/createOrder - Use plural nouns:
/usersnot/user - Use kebab-case for multi-word resources:
/invoice-itemsnot/invoiceItemsor/invoice_items - Nest resources to show relationships:
/users/123/ordersbut avoid deep nesting (max 2 levels)
```typescript // Good GET /users GET /users/:id POST /users PATCH /users/:id DELETE /users/:id GET /users/:id/orders GET /orders/:id/items
// Bad GET /getUser POST /createNewUser DELETE /removeUserById GET /users/:id/getOrders ```
Actions on Resources
For non-CRUD operations, use a sub-resource with the action:
``typescript POST /orders/:id/cancel POST /users/:id/activate POST /invoices/:id/refund ``
Avoid generic /api/execute endpoints that accept an action parameter.
API Versioning
Version your API from day one, even if you only have one consumer. The version indicator should be stable and explicit.
Preferred approach: URL path versioning
``typescript GET /api/v1/users GET /api/v2/users ``
Alternative: Header-based versioning
``typescript GET /users Headers: Accept: application/vnd.api.v2+json ``
URL path versioning is simpler for consumers to understand and test. Header-based versioning is cleaner but harder to debug and cache.
Versioning Strategy
- v1: Stable, supported for 12 months after v2 release
- v2: New features, breaking changes
- Deprecation: Include a
Sunsetheader on deprecated versions
``typescript res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT'); res.setHeader('Deprecation', 'true'); ``
Pagination
Never return unbounded result sets. A request for /users without pagination is a performance incident waiting to happen.
Cursor-Based Pagination (Recommended)
Cursor-based pagination is stable even when data changes — new records don't shift page boundaries.
```typescript interface PaginatedResponse<T> { data: T[]; nextCursor: string | null; hasMore: boolean; }
// Request GET /api/v1/users?cursor=abc123&limit=20
// Implementation async function getUsers(cursor?: string, limit: number = 20) { let query = db.collection('users') .orderBy('createdAt') .limit(limit + 1); // fetch one extra to detect hasMore
if (cursor) { const cursorDoc = await db.collection('users').doc(cursor).get(); query = query.startAfter(cursorDoc); }
const snapshot = await query.get(); const users = snapshot.docs.slice(0, limit).map(d => d.data()); const hasMore = snapshot.docs.length > limit;
return { data: users, nextCursor: hasMore ? snapshot.docs[limit - 1].id : null, hasMore, }; } ```
Offset Pagination (Simple but Flawed)
Works for small datasets but breaks when records are inserted or deleted between requests:
``typescript GET /api/v1/users?page=2&limit=20 // Response { "data": [...], "page": 2, "limit": 20, "total": 150, "totalPages": 8 } ``
Use offset pagination only for admin dashboards or static datasets.
Standardized Error Responses
Consistent error formats make API clients significantly easier to build and debug.
```typescript interface ApiError { error: { code: string; // Machine-readable: "VALIDATION_ERROR" message: string; // Human-readable: "Email is required" details?: ErrorDetail[]; // Field-level errors requestId?: string; // Correlation ID for debugging docs?: string; // Link to documentation }; }
interface ErrorDetail { field: string; // "email" message: string; // "must be a valid email address" code: string; // "INVALID_FORMAT" }
// Usage function validationError(errors: ErrorDetail[]): Response { return { status: 422, body: { error: { code: 'VALIDATION_ERROR', message: 'Request validation failed', details: errors, requestId: req.id, }, }, }; } ```
HTTP Status Codes
Use the correct HTTP status codes consistently:
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Malformed request body |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, version conflict |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
Rate Limiting
Protect your API from abuse and ensure fair usage:
```typescript import { RateLimiterMemory } from 'rate-limiter-flexible';
const rateLimiter = new RateLimiterMemory({ points: 100, // Number of requests duration: 60, // Per 60 seconds blockDuration: 120, // Block for 2 minutes if exceeded });
async function rateLimitMiddleware(req, res, next) { try { const key = req.headers['x-api-key'] || req.ip; await rateLimiter.consume(key);
// Expose rate limit headers const remaining = await rateLimiter.get(key); res.setHeader('X-RateLimit-Limit', 100); res.setHeader('X-RateLimit-Remaining', remaining?.remainingPoints ?? 0); res.setHeader('X-RateLimit-Reset', remaining?.msBeforeNext ?? 0);
next(); } catch { res.setHeader('Retry-After', 120); res.status(429).json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later.', }, }); } } ```
Authentication and Authorization
Include authentication requirements in the OpenAPI/Swagger spec and use standard headers:
```typescript // Bearer token in Authorization header Headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiIs...' }
// Or API key in custom header Headers: { 'X-API-Key': 'sk_live_abc123' } ```
Never accept authentication tokens in URL query parameters — they get logged by proxies and servers.
API Documentation
Generate documentation from your code using OpenAPI 3.0:
```typescript // Using zod-to-openapi import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod';
const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1).max(100), createdAt: z.string().datetime(), });
const generator = new OpenApiGeneratorV3([UserSchema]); const spec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'User API', version: '1.0.0' }, }); ```
Conclusion
Great API design is about consistency and predictability. Every endpoint should follow the same patterns for naming, errors, pagination, and authentication. When consumers can guess how your API works without reading documentation, you've succeeded. Invest in your API design early — changing it later means breaking every consumer.
Designing a new API or refactoring an existing one? Let's talk about API architecture best practices for your project.
---