Skip to main content

Command Palette

Search for a command to run...

Create REST API: Node Express MongoDB

Published
9 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Building Production-Ready REST APIs with Node.js, Express, and MongoDB: A 2025 Guide

When you create REST API Node Express MongoDB applications today, you're not just connecting three technologies—you're architecting systems that must handle millions of requests, comply with data privacy regulations like GDPR and CCPA, integrate with AI-driven services, and scale horizontally across distributed cloud environments. The stakes are significantly higher than they were even two years ago.

The problem is that most tutorials still teach patterns from 2018-2020: callback-heavy code, loose typing, minimal validation, and monolithic architectures that collapse under modern load patterns. Teams following outdated approaches face cascading failures during traffic spikes, data inconsistencies from race conditions, security vulnerabilities from improper input validation, and technical debt that makes AI integration nearly impossible. In 2025, a poorly architected REST API doesn't just perform badly—it becomes a compliance liability and a bottleneck for your entire product ecosystem.

Why Traditional Node-Express-MongoDB Patterns Fail Modern Requirements

The classic approach of spinning up Express with basic routes and Mongoose models worked when applications served thousands of users with predictable traffic patterns. Today's reality is fundamentally different.

Modern applications face concurrent requests from web clients, mobile apps, IoT devices, and AI agents—all with different authentication contexts and rate limit requirements. Privacy regulations require granular audit trails and data residency controls. Real-time features demand WebSocket connections alongside REST endpoints. Observability requirements mean every request needs structured logging, distributed tracing, and metric collection.

The traditional callback-based, loosely-typed approach creates several critical problems:

Type safety gaps lead to runtime errors that only surface in production. Without TypeScript, refactoring becomes dangerous, and integration with modern AI SDKs (which expect strict types) becomes painful.

Inadequate error handling causes information leakage in error responses, making debugging difficult while exposing internal architecture to attackers.

Missing validation layers allow malformed data into your database, creating data quality issues that corrupt analytics and break downstream services.

Naive database connection patterns exhaust connection pools under load, causing cascading failures across your infrastructure.

Modern Architecture for Node Express MongoDB REST APIs

Let's build a production-grade REST API using TypeScript, modern Express patterns, and MongoDB with proper connection pooling, validation, and error handling.

Project Structure and Foundation

// src/types/index.ts
export interface User {
  _id?: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
  createdAt: Date;
  updatedAt: Date;
}

export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
  metadata?: {
    page?: number;
    limit?: number;
    total?: number;
  };
}

export interface RequestContext {
  userId: string;
  role: string;
  requestId: string;
}

Database Layer with Connection Resilience

// src/database/connection.ts
import mongoose from 'mongoose';
import { logger } from '../utils/logger';

interface ConnectionOptions {
  maxPoolSize: number;
  minPoolSize: number;
  socketTimeoutMS: number;
  serverSelectionTimeoutMS: number;
  heartbeatFrequencyMS: number;
}

export class DatabaseConnection {
  private static instance: DatabaseConnection;
  private isConnected = false;

  private constructor() {}

  static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection();
    }
    return DatabaseConnection.instance;
  }

  async connect(uri: string): Promise<void> {
    if (this.isConnected) {
      logger.info('Database already connected');
      return;
    }

    const options: ConnectionOptions = {
      maxPoolSize: 10,
      minPoolSize: 2,
      socketTimeoutMS: 45000,
      serverSelectionTimeoutMS: 5000,
      heartbeatFrequencyMS: 10000,
    };

    try {
      await mongoose.connect(uri, options);
      this.isConnected = true;

      mongoose.connection.on('error', (error) => {
        logger.error('MongoDB connection error:', error);
        this.isConnected = false;
      });

      mongoose.connection.on('disconnected', () => {
        logger.warn('MongoDB disconnected. Attempting reconnection...');
        this.isConnected = false;
      });

      mongoose.connection.on('reconnected', () => {
        logger.info('MongoDB reconnected');
        this.isConnected = true;
      });

      logger.info('MongoDB connected successfully');
    } catch (error) {
      logger.error('Failed to connect to MongoDB:', error);
      throw error;
    }
  }

  async disconnect(): Promise<void> {
    if (!this.isConnected) return;
    await mongoose.disconnect();
    this.isConnected = false;
    logger.info('MongoDB disconnected');
  }

  getConnectionStatus(): boolean {
    return this.isConnected;
  }
}

Schema Design with Validation

// src/models/User.ts
import mongoose, { Schema, Model } from 'mongoose';
import { User } from '../types';

const userSchema = new Schema<User>(
  {
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
      match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      index: true,
    },
    name: {
      type: String,
      required: true,
      trim: true,
      minlength: 2,
      maxlength: 100,
    },
    role: {
      type: String,
      enum: ['user', 'admin'],
      default: 'user',
      index: true,
    },
  },
  {
    timestamps: true,
    toJSON: {
      transform: (_, ret) => {
        ret.id = ret._id.toString();
        delete ret._id;
        delete ret.__v;
        return ret;
      },
    },
  }
);

// Compound index for common queries
userSchema.index({ role: 1, createdAt: -1 });

export const UserModel: Model<User> = mongoose.model<User>('User', userSchema);

Request Validation Middleware

// src/middleware/validation.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
import { ApiResponse } from '../types';

export const validate = (schema: ZodSchema) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = await schema.parseAsync(req.body);
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        const response: ApiResponse<never> = {
          success: false,
          error: {
            code: 'VALIDATION_ERROR',
            message: 'Invalid request data',
            details: error.errors.map((e) => ({
              path: e.path.join('.'),
              message: e.message,
            })),
          },
        };
        return res.status(400).json(response);
      }
      next(error);
    }
  };
};

// src/schemas/user.schema.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string().min(2).max(100),
  role: z.enum(['user', 'admin']).optional(),
});

export const updateUserSchema = createUserSchema.partial();

export const queryUserSchema = z.object({
  page: z.string().regex(/^\d+$/).transform(Number).optional(),
  limit: z.string().regex(/^\d+$/).transform(Number).optional(),
  role: z.enum(['user', 'admin']).optional(),
});

Error Handling Architecture

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ApiResponse } from '../types';
import { logger } from '../utils/logger';

export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export const errorHandler = (
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  logger.error('Error occurred:', {
    error: error.message,
    stack: error.stack,
    path: req.path,
    method: req.method,
    requestId: req.headers['x-request-id'],
  });

  if (error instanceof AppError) {
    const response: ApiResponse<never> = {
      success: false,
      error: {
        code: error.code,
        message: error.message,
      },
    };
    return res.status(error.statusCode).json(response);
  }

  // MongoDB duplicate key error
  if (error.name === 'MongoServerError' && (error as any).code === 11000) {
    const response: ApiResponse<never> = {
      success: false,
      error: {
        code: 'DUPLICATE_ENTRY',
        message: 'Resource already exists',
      },
    };
    return res.status(409).json(response);
  }

  // Default error response
  const response: ApiResponse<never> = {
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    },
  };
  res.status(500).json(response);
};

Controller Implementation with Business Logic

// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserModel } from '../models/User';
import { ApiResponse, User } from '../types';
import { AppError } from '../middleware/errorHandler';

export class UserController {
  async createUser(req: Request, res: Response, next: NextFunction) {
    try {
      const userData = req.body;
      const user = await UserModel.create(userData);

      const response: ApiResponse<User> = {
        success: true,
        data: user.toJSON(),
      };

      res.status(201).json(response);
    } catch (error) {
      next(error);
    }
  }

  async getUsers(req: Request, res: Response, next: NextFunction) {
    try {
      const { page = 1, limit = 20, role } = req.query;
      const skip = (Number(page) - 1) * Number(limit);

      const filter = role ? { role } : {};

      const [users, total] = await Promise.all([
        UserModel.find(filter)
          .sort({ createdAt: -1 })
          .skip(skip)
          .limit(Number(limit))
          .lean(),
        UserModel.countDocuments(filter),
      ]);

      const response: ApiResponse<User[]> = {
        success: true,
        data: users,
        metadata: {
          page: Number(page),
          limit: Number(limit),
          total,
        },
      };

      res.json(response);
    } catch (error) {
      next(error);
    }
  }

  async getUserById(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const user = await UserModel.findById(id).lean();

      if (!user) {
        throw new AppError(404, 'USER_NOT_FOUND', 'User not found');
      }

      const response: ApiResponse<User> = {
        success: true,
        data: user,
      };

      res.json(response);
    } catch (error) {
      next(error);
    }
  }

  async updateUser(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const updates = req.body;

      const user = await UserModel.findByIdAndUpdate(
        id,
        { $set: updates },
        { new: true, runValidators: true }
      ).lean();

      if (!user) {
        throw new AppError(404, 'USER_NOT_FOUND', 'User not found');
      }

      const response: ApiResponse<User> = {
        success: true,
        data: user,
      };

      res.json(response);
    } catch (error) {
      next(error);
    }
  }

  async deleteUser(req: Request, res: Response, next: NextFunction) {
    try {
      const { id } = req.params;
      const user = await UserModel.findByIdAndDelete(id).lean();

      if (!user) {
        throw new AppError(404, 'USER_NOT_FOUND', 'User not found');
      }

      const response: ApiResponse<{ id: string }> = {
        success: true,
        data: { id },
      };

      res.json(response);
    } catch (error) {
      next(error);
    }
  }
}

Route Configuration

// src/routes/user.routes.ts
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { validate } from '../middleware/validation';
import { createUserSchema, updateUserSchema, queryUserSchema } from '../schemas/user.schema';

const router = Router();
const userController = new UserController();

router.post('/', validate(createUserSchema), userController.createUser);
router.get('/', validate(queryUserSchema), userController.getUsers);
router.get('/:id', userController.getUserById);
router.patch('/:id', validate(updateUserSchema), userController.updateUser);
router.delete('/:id', userController.deleteUser);

export default router;

Application Bootstrap

// src/app.ts
import express, { Application } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import { rateLimit } from 'express-rate-limit';
import userRoutes from './routes/user.routes';
import { errorHandler } from './middleware/errorHandler';
import { DatabaseConnection } from './database/connection';
import { logger } from './utils/logger';

export class App {
  public app: Application;
  private db: DatabaseConnection;

  constructor() {
    this.app = express();
    this.db = DatabaseConnection.getInstance();
    this.initializeMiddlewares();
    this.initializeRoutes();
    this.initializeErrorHandling();
  }

  private initializeMiddlewares(): void {
    this.app.use(helmet());
    this.app.use(cors({
      origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
      credentials: true,
    }));
    this.app.use(compression());
    this.app.use(express.json({ limit: '10mb' }));
    this.app.use(express.urlencoded({ extended: true, limit: '10mb' }));

    const limiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 100,
      message: 'Too many requests from this IP',
    });
    this.app.use('/api/', limiter);

    this.app.use((req, res, next) => {
      req.headers['x-request-id'] = req.headers['x-request-id'] || 
        `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
      next();
    });
  }

  private initializeRoutes(): void {
    this.app.get('/health', (req, res) => {
      res.json({
        status: 'healthy',
        timestamp: new Date().toISOString(),
        database: this.db.getConnectionStatus(),
      });
    });

    this.app.use('/api/users', userRoutes);
  }

  private initializeErrorHandling(): void {
    this.app.use(errorHandler);
  }

  async connectDatabase(): Promise<void> {
    const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp';
    await this.db.connect(mongoUri);
  }

  async start(port: number): Promise<void> {
    await this.connectDatabase();
    this.app.listen(port, () => {
      logger.info(`Server running on port ${port}`);
    });
  }
}

// src/server.ts
import { App } from './app';

const port = parseInt(process.env.PORT || '3000', 10);
const app = new App();

app.start(port).catch((error) => {
  console.error('Failed to start server:', error);
  process.exit(1);
});

process.on('SIGTERM', async () => {
  console.log('SIGTERM received, shutting down gracefully');
  process.exit(0);
});

Common Pitfalls and Edge Cases

Connection pool exhaustion occurs when you don't properly configure MongoDB connection limits. Always set maxPoolSize based on your expected concurrent request load and available database connections.

Memory leaks from event listeners happen when you attach MongoDB connection event handlers without cleanup. Use the singleton pattern shown above to prevent duplicate listeners.

Race conditions in concurrent updates emerge when multiple requests modify the same document. Use MongoDB's atomic operators ($inc, $push, $set) and implement optimistic locking with version fields when necessary.

Validation bypass through query parameters is common when developers validate request bodies but ignore query strings. Always validate query parameters using schemas.

Improper error information exposure leaks internal architecture details. Never send raw error objects to clients—always transform them through your error handler.

Index missing on filtered queries causes full collection scans. Create compound indexes for common query patterns, especially those combining filters with sorting.

Timezone inconsistencies arise when storing dates without proper UTC handling. Always store dates in UTC and convert to local timezones only in the presentation layer.

Best Practices for Production REST APIs

Implement structured logging with correlation IDs that track requests across services. Use Winston or Pino with JSON formatting for easy parsing in log aggregation systems.

Add health check endpoints that verify database connectivity, external service availability, and system resources. Kubernetes and other orchestrators depend on these for traffic routing.

Use environment-specific configuration through environment variables, never hardcode credentials or endpoints. Validate all required environment variables at startup.

Implement request timeouts at multiple levels: database queries, HTTP requests to external services, and overall request handling. Default to 30 seconds for API requests.

Enable compression for response payloads over 1KB. This reduces bandwidth costs and improves response times for clients on slower connections.

Version your API from day one using URL prefixes (/api/v1/) or headers. This allows breaking changes without disrupting existing clients.

Monitor key metrics including request latency (p50, p95, p99), error rates, database connection pool usage, and memory consumption. Set up alerts for anomalies.

Implement graceful shutdown that stops accepting new requests, completes in-flight requests, and closes database connections cleanly when receiving SIGTERM signals.

**Use TypeScript