Skip to content
    2026-01-15|6 min read

    Designing RESTful APIs That Scale: Best Practices

    #api#rest#backend#nodejs#architecture

    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: /orders not /getOrders or /createOrder
    • Use plural nouns: /users not /user
    • Use kebab-case for multi-word resources: /invoice-items not /invoiceItems or /invoice_items
    • Nest resources to show relationships: /users/123/orders but 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 Sunset header 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:

    CodeMeaningWhen to Use
    200OKSuccessful GET, PATCH
    201CreatedSuccessful POST
    204No ContentSuccessful DELETE
    400Bad RequestMalformed request body
    401UnauthorizedMissing or invalid authentication
    403ForbiddenAuthenticated but not authorized
    404Not FoundResource doesn't exist
    409ConflictDuplicate resource, version conflict
    422Unprocessable EntityValidation errors
    429Too Many RequestsRate limit exceeded
    500Internal Server ErrorUnexpected 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.

    ---

    R

    Written by

    Rahul

    Freelance developer for startups building SaaS products, MVPs, mobile apps, and conversion-focused website improvements.

    Building something?

    I am currently available for new projects. Share your idea and I will give you an honest assessment, delivery plan, and quote.