Building Production-Ready REST APIs with Express.js

Express.js is the most popular Node.js web framework. Its flexibility means you must make deliberate decisions about structure and middleware to build something production-ready.

1. Project Structure

src/
├── config/          # Environment configuration
├── middleware/      # Express middleware
├── routes/          # Route definitions (thin)
├── controllers/     # Request handling
├── services/        # Business logic
├── models/          # Data access
├── validators/      # Request schemas
├── errors/          # Custom error classes
└── app.js           # Express setup

Key principle: Routes don't contain logic, controllers don't access the database, services don't format HTTP responses.

// routes/users.js — thin
router.get('/:id', authenticate, usersController.getById);

// controllers/users.js — request handling
exports.getById = async (req, res, next) => {
  try {
    const user = await usersService.getById(req.params.id);
    res.json({ data: user });
  } catch (err) {
    next(err);
  }
};

// services/users.js — business logic
exports.getById = async (id) => {
  const user = await User.findByPk(id);
  if (!user) throw new NotFoundError('User not found');
  return user;
};

2. Centralized Error Handling

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  if (!err.isOperational) {
    logger.error('Unexpected error:', err);
  }

  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: err.isOperational ? err.message : 'An unexpected error occurred',
    },
  });
}

Mount it last: app.use(errorHandler);

3. Input Validation with Joi

// validators/user.js
const Joi = require('joi');

exports.createUser = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).max(128).required(),
  name: Joi.string().min(1).max(100).required(),
});

// middleware/validate.js
const validate = (schemaName) => (req, res, next) => {
  const schema = require('../validators')[schemaName];
  const { error, value } = schema.validate(req.body, {
    abortEarly: false, stripUnknown: true,
  });

  if (error) {
    const messages = error.details.map(d => d.message).join('; ');
    return next(new ValidationError(messages));
  }

  req.body = value;
  next();
};

// Usage
router.post('/', validate('createUser'), usersController.create);

4. Rate Limiting

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,
  standardHeaders: true,
  message: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } },
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,  // 10 login attempts per 15 min
  skipSuccessfulRequests: true,
});

app.use('/api/', apiLimiter);
app.use('/api/users/login', authLimiter);

5. Logging and Request Tracing

const { v4: uuidv4 } = require('uuid');

// Request ID
app.use((req, res, next) => {
  req.requestId = req.headers['x-request-id'] || uuidv4();
  res.setHeader('x-request-id', req.requestId);
  next();
});

// Request logging
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({ requestId: req.requestId, method: req.method,
      url: req.originalUrl, status: res.statusCode,
      duration: Date.now() - start });
  });
  next();
});

6. Health Checks and Graceful Shutdown

app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

function gracefulShutdown(signal) {
  console.log(`Received ${signal}, shutting down...`);
  server.close(() => { db.close(); process.exit(0); });
  setTimeout(() => { process.exit(1); }, 30000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

7. Security Middleware

const helmet = require('helmet');
const cors = require('cors');

app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json({ limit: '10kb' }));

Final Assembly

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

const app = express();
app.use(helmet());
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ limit: '10kb' }));
app.use(requestId);
app.use(requestLogger);
app.use('/api', routes);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use(errorHandler);

module.exports = app;

The difference between a hobby Express app and a production Express app isn't the framework — it's the structure: layered architecture, centralized error handling, input validation, rate limiting, and proper configuration management.