Building Scalable APIs with Node.js and Express

Creating APIs that can handle thousands of concurrent requests while maintaining performance and reliability requires careful planning and implementation. This guide covers essential patterns and practices for building production-ready APIs.

Project Structure and Setup

Organized Architecture

api-server/
├── src/
│   ├── controllers/       # Request handlers
│   ├── middleware/        # Custom middleware
│   ├── models/           # Data models
│   ├── routes/           # Route definitions
│   ├── services/         # Business logic
│   ├── utils/            # Helper functions
│   ├── validators/       # Input validation
│   └── config/           # Configuration files
├── tests/
├── docs/
└── package.json

Environment Configuration

// src/config/database.ts
import { z } from "zod";

const envSchema = z.object({
    NODE_ENV: z.enum(["development", "production", "test"]),
    PORT: z.string().transform(Number),
    DATABASE_URL: z.string().url(),
    JWT_SECRET: z.string().min(32),
    REDIS_URL: z.string().url().optional(),
});

export const config = envSchema.parse(import.meta.env);

// src/config/database.ts
import { Pool } from "pg";
import { config } from "./env";

export const pool = new Pool({
    connectionString: config.DATABASE_URL,
    max: 20,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000,
});

Request Validation and Error Handling

Input Validation with Zod

// src/validators/user.ts
import { z } from "zod";

export const createUserSchema = z.object({
    body: z.object({
        email: z.string().email(),
        password: z.string().min(8),
        name: z.string().min(2).max(50),
        role: z.enum(["user", "admin"]).default("user"),
    }),
});

export const getUserSchema = z.object({
    params: z.object({
        id: z.string().uuid(),
    }),
});

export type CreateUserRequest = z.infer<typeof createUserSchema>;
export type GetUserRequest = z.infer<typeof getUserSchema>;

Validation Middleware

// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";

export const validate = (schema: AnyZodObject) => {
    return async (req: Request, res: Response, next: NextFunction) => {
        try {
            await schema.parseAsync({
                body: req.body,
                query: req.query,
                params: req.params,
            });
            next();
        } catch (error) {
            if (error instanceof ZodError) {
                return res.status(400).json({
                    error: "Validation failed",
                    details: error.errors.map((err) => ({
                        field: err.path.join("."),
                        message: err.message,
                    })),
                });
            }
            next(error);
        }
    };
};

Global Error Handler

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { config } from "../config/env";

export class AppError extends Error {
    public statusCode: number;
    public isOperational: boolean;

    constructor(message: string, statusCode: number) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true;
        Error.captureStackTrace(this, this.constructor);
    }
}

export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
    let statusCode = 500;
    let message = "Internal Server Error";

    if (err instanceof AppError) {
        statusCode = err.statusCode;
        message = err.message;
    }

    // Log error for monitoring
    console.error("Error:", {
        message: err.message,
        stack: err.stack,
        url: req.url,
        method: req.method,
        ip: req.ip,
        userAgent: req.get("User-Agent"),
    });

    res.status(statusCode).json({
        error: message,
        ...(config.NODE_ENV === "development" && { stack: err.stack }),
    });
};

Database Layer with Repository Pattern

Repository Interface

// src/repositories/BaseRepository.ts
export interface Repository<T> {
    create(data: Partial<T>): Promise<T>;
    findById(id: string): Promise<T | null>;
    findMany(filters?: Partial<T>): Promise<T[]>;
    update(id: string, data: Partial<T>): Promise<T>;
    delete(id: string): Promise<void>;
}

// src/repositories/UserRepository.ts
import { pool } from "../config/database";
import { Repository } from "./BaseRepository";

export interface User {
    id: string;
    email: string;
    name: string;
    role: "user" | "admin";
    createdAt: Date;
    updatedAt: Date;
}

export class UserRepository implements Repository<User> {
    async create(userData: Partial<User>): Promise<User> {
        const query = `
      INSERT INTO users (email, name, role, password_hash)
      VALUES ($1, $2, $3, $4)
      RETURNING id, email, name, role, created_at, updated_at
    `;

        const result = await pool.query(query, [userData.email, userData.name, userData.role, userData.passwordHash]);

        return this.mapRowToUser(result.rows[0]);
    }

    async findById(id: string): Promise<User | null> {
        const query = "SELECT * FROM users WHERE id = $1";
        const result = await pool.query(query, [id]);

        return result.rows[0] ? this.mapRowToUser(result.rows[0]) : null;
    }

    private mapRowToUser(row: any): User {
        return {
            id: row.id,
            email: row.email,
            name: row.name,
            role: row.role,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
        };
    }
}

Service Layer for Business Logic

// src/services/UserService.ts
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { UserRepository, User } from "../repositories/UserRepository";
import { AppError } from "../middleware/errorHandler";
import { config } from "../config/env";

export class UserService {
    constructor(private userRepository: UserRepository) {}

    async createUser(userData: {
        email: string;
        password: string;
        name: string;
        role?: "user" | "admin";
    }): Promise<User> {
        // Check if user already exists
        const existingUser = await this.userRepository.findByEmail(userData.email);
        if (existingUser) {
            throw new AppError("User already exists", 409);
        }

        // Hash password
        const passwordHash = await bcrypt.hash(userData.password, 12);

        // Create user
        const user = await this.userRepository.create({
            ...userData,
            passwordHash,
        });

        return user;
    }

    async authenticateUser(email: string, password: string): Promise<{ user: User; token: string }> {
        const user = await this.userRepository.findByEmail(email);
        if (!user) {
            throw new AppError("Invalid credentials", 401);
        }

        const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
        if (!isPasswordValid) {
            throw new AppError("Invalid credentials", 401);
        }

        const token = jwt.sign({ userId: user.id, role: user.role }, config.JWT_SECRET, { expiresIn: "24h" });

        return { user, token };
    }
}

Rate Limiting and Security

Rate Limiting Middleware

// src/middleware/rateLimiter.ts
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import Redis from "ioredis";
import { config } from "../config/env";

const redis = new Redis(config.REDIS_URL);

export const createRateLimiter = (options: { windowMs: number; max: number; message?: string }) => {
    return rateLimit({
        store: new RedisStore({
            sendCommand: (...args: string[]) => redis.call(...args),
        }),
        windowMs: options.windowMs,
        max: options.max,
        message: options.message || "Too many requests",
        standardHeaders: true,
        legacyHeaders: false,
    });
};

// Usage examples
export const authLimiter = createRateLimiter({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 attempts per window
    message: "Too many authentication attempts",
});

export const apiLimiter = createRateLimiter({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // 100 requests per window
});

Security Headers

// src/middleware/security.ts
import helmet from "helmet";
import cors from "cors";

export const securityMiddleware = [
    helmet({
        contentSecurityPolicy: {
            directives: {
                defaultSrc: ["'self'"],
                styleSrc: ["'self'", "'unsafe-inline'"],
                scriptSrc: ["'self'"],
                imgSrc: ["'self'", "data:", "https:"],
            },
        },
    }),
    cors({
        origin: import.meta.env.ALLOWED_ORIGINS?.split(",") || "http://localhost:3000",
        credentials: true,
    }),
];

API Documentation with OpenAPI

// src/docs/swagger.ts
import swaggerJsdoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";

const options = {
    definition: {
        openapi: "3.0.0",
        info: {
            title: "Scalable API",
            version: "1.0.0",
            description: "A scalable Node.js API with Express",
        },
        servers: [
            {
                url: "http://localhost:3000/api/v1",
                description: "Development server",
            },
        ],
        components: {
            securitySchemes: {
                bearerAuth: {
                    type: "http",
                    scheme: "bearer",
                    bearerFormat: "JWT",
                },
            },
        },
    },
    apis: ["./src/routes/*.ts"], // paths to files containing OpenAPI definitions
};

export const specs = swaggerJsdoc(options);
export const swaggerUiOptions = {
    explorer: true,
    customCss: ".swagger-ui .topbar { display: none }",
};

Performance Monitoring

// src/middleware/monitoring.ts
import prometheus from "prom-client";

// Create metrics
const httpRequestDuration = new prometheus.Histogram({
    name: "http_request_duration_ms",
    help: "Duration of HTTP requests in ms",
    labelNames: ["method", "route", "status_code"],
    buckets: [1, 5, 15, 50, 100, 500, 1000],
});

const httpRequestsTotal = new prometheus.Counter({
    name: "http_requests_total",
    help: "Total number of HTTP requests",
    labelNames: ["method", "route", "status_code"],
});

export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
    const start = Date.now();

    res.on("finish", () => {
        const duration = Date.now() - start;
        const route = req.route?.path || req.path;

        httpRequestDuration.labels(req.method, route, res.statusCode.toString()).observe(duration);

        httpRequestsTotal.labels(req.method, route, res.statusCode.toString()).inc();
    });

    next();
};

// Metrics endpoint
export const metricsHandler = async (req: Request, res: Response) => {
    res.set("Content-Type", prometheus.register.contentType);
    res.end(await prometheus.register.metrics());
};

Testing Strategy

// tests/integration/users.test.ts
import request from "supertest";
import { app } from "../../src/app";
import { pool } from "../../src/config/database";

describe("Users API", () => {
    beforeEach(async () => {
        await pool.query("TRUNCATE TABLE users CASCADE");
    });

    afterAll(async () => {
        await pool.end();
    });

    describe("POST /api/v1/users", () => {
        it("should create a new user", async () => {
            const userData = {
                email: "test@example.com",
                password: "password123",
                name: "Test User",
            };

            const response = await request(app).post("/api/v1/users").send(userData).expect(201);

            expect(response.body).toMatchObject({
                id: expect.any(String),
                email: userData.email,
                name: userData.name,
                role: "user",
            });
            expect(response.body.password).toBeUndefined();
        });

        it("should return 400 for invalid email", async () => {
            const response = await request(app)
                .post("/api/v1/users")
                .send({
                    email: "invalid-email",
                    password: "password123",
                    name: "Test User",
                })
                .expect(400);

            expect(response.body.error).toBe("Validation failed");
        });
    });
});

Conclusion

Building scalable APIs requires attention to architecture, security, performance, and maintainability. The patterns and practices covered in this guide provide a solid foundation for production-ready Node.js applications.

Key takeaways:

  • Structure your code with clear separation of concerns
  • Implement comprehensive validation and error handling
  • Use rate limiting and security headers to protect your API
  • Monitor performance and implement proper logging
  • Write tests to ensure reliability and catch regressions

Remember to continuously monitor and optimize your API as it grows and evolves with your application's needs.