DEV Community

Alex Chen
Alex Chen

Posted on

Node.js Express: Building Real APIs That Scale (2026)

Node.js Express: Building Real APIs That Scale (2026)

Express is the most popular Node.js framework for good reason — it's simple, flexible, and battle-tested. Here's how to build production-ready APIs with it.

Project Structure That Scales

my-api/
├── src/
│   ├── index.js              # Entry point
│   ├── app.js                # Express app setup (no server start!)
│   ├── config/
│   │   └── index.js          # Environment-based configuration
│   ├── routes/
│   │   ├── index.js          # Route aggregation
│   │   ├── users.js          # User routes
│   │   ├── posts.js          # Post routes
│   │   └── health.js         # Health check endpoint
│   ├── controllers/          # Route handlers (thin)
│   │   ├── userController.js
│   │   └── postController.js
│   ├── services/             # Business logic (no HTTP concerns)
│   │   ├── userService.js
│   │   └── postService.js
│   ├── middleware/
│   │   ├── auth.js           # Authentication
│   │   ├── validate.js       # Request validation
│   │   ├── errorHandler.js   # Global error handler
│   │   └── rateLimit.js      # Rate limiting
│   ├── models/               # Data models/schemas
│   │   ├── User.js
│   │   └── Post.js
│   ├── utils/
│   │   ├── logger.js         # Winston/Pino logger
│   │   ├── response.js       # Standardized response helpers
│   │   └── asyncHandler.js   # Try-catch wrapper
│   └── db/
│       └── connection.js     # Database connection pool
├── tests/
│   ├── unit/
│   └── integration/
├── .env.example               # Template for environment variables
├── ecosystem.config.js        # PM2 cluster config
└── package.json
Enter fullscreen mode Exit fullscreen mode

The App Setup

// src/app.js — Pure Express app, no server listen!
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const routes = require('./routes');

function createApp() {
  const app = express();

  // === Security Middleware (order matters!) ===
  app.use(helmet());                    // Security headers FIRST

  // CORS configuration:
  const allowedOrigins = [
    'https://myapp.com',
    ...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000', 'http://localhost:5173'] : []),
  ];

  app.use(cors({
    origin: (origin, callback) => {
      if (!origin || allowedOrigins.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error('Not allowed by CORS'));
      }
    },
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  }));

  // Body parsing:
  app.use(express.json({ limit: '1mb' }));      // JSON body (size limit!)
  app.use(express.urlencoded({ extended: true, limit: '1mb' }));

  // Request logging (skip in tests):
  if (process.env.NODE_ENV !== 'test') {
    app.use(morgan(process.env.LOG_FORMAT || 'combined', {
      stream: require('./utils/logger').httpStream,
    }));
  }

  // Request ID for tracing:
  app.use((req, res, next) => {
    req.id = req.headers['x-request-id'] || crypto.randomUUID();
    res.setHeader('X-Request-ID', req.id);
    next();
  });

  // Rate limiting:
  app.use('/api/', rateLimit({
    windowMs: 60 * 1000,
    max: 100,
    standardHeaders: true,
    legacyHeaders: false,
    message: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
  }));

  // Trust proxy (behind Nginx/reverse proxy):
  app.set('trust proxy', 1);

  // === Routes ===
  app.get('/health', (req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString(), pid: process.pid });
  });

  app.use('/api/v1', routes);

  // === Error Handling (MUST be last middleware) ===
  app.use(require('./middleware/errorHandler'));

  // 404 handler (no route matched):
  app.use((req, res) => {
    res.status(404).json({
      error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` }
    });
  });

  return app;
}

module.exports = createApp;
Enter fullscreen mode Exit fullscreen mode

Route Handlers & Controllers

// src/utils/asyncHandler.js — Eliminates try/catch boilerplate
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;

// src/middleware/auth.js — JWT Authentication
const jwt = require('jsonwebtoken');

function auth(options = {}) {
  return (req, res, next) => {
    const header = req.headers.authorization;

    if (!header?.startsWith('Bearer ')) {
      return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing token' } });
    }

    const token = header.split(' ')[1];

    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      req.user = decoded; // Attach user to request
      next();
    } catch (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: { code: 'TOKEN_EXPIRED', message: 'Token expired' } });
      }
      return res.status(401).json({ error: { code: 'INVALID_TOKEN', message: 'Invalid token' } });
    }
  };
}

// Role-based access control:
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' } });
    }
    next();
  };
}

// src/controllers/userController.js
const userService = require('../services/userService');
const asyncHandler = require('../utils/asyncHandler');
const { validate } = require('../middleware/validate');

// Validation schemas (using Joi or Zod):
const createUserSchema = {
  body: {
    email: 'required|email',
    name: 'required|string|min:2|max:100',
    password: 'required|string|min:8',
  },
};

module.exports = {
  listUsers: asyncHandler(async (req, res) => {
    const { page = 1, limit = 20, sort = '-createdAt' } = req.query;
    const result = await userService.findAll({ page: parseInt(limit), page: parseInt(page), sort });
    res.json({ data: result.items, meta: result.pagination });
  }),

  getUser: asyncHandler(async (req, res) => {
    const user = await userService.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
    }
    res.json({ data: user });
  }),

  createUser: asyncHandler(async (req, res) => {
    const user = await userService.create(req.body); // Already validated by middleware
    res.status(201).json({ data: user });
  }),

  updateUser: asyncHandler(async (req, res) => {
    const user = await userService.update(req.params.id, req.body);
    if (!user) {
      return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
    }
    res.json({ data: user });
  }),

  deleteUser: asyncHandler(async (req, res) => {
    const deleted = await userService.remove(req.params.id);
    if (!deleted) {
      return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
    }
    res.status(204).send();
  }),
};
Enter fullscreen mode Exit fullscreen mode

Service Layer (Business Logic)

// src/services/userService.js — No HTTP knowledge here!
const db = require('../db/connection');
const bcrypt = require('bcrypt');
const { AppError } = require('../utils/errors');

class UserService {
  async findAll({ page = 1, limit = 20, sort = '-createdAt' }) {
    const offset = (page - 1) * limit;
    const [items, count] = await Promise.all([
      db('users')
        .select('id', 'name', 'email', 'role', 'created_at')
        .orderBy(sort.replace('-', ''), sort.startsWith('-') ? 'desc' : 'asc')
        .limit(limit)
        .offset(offset),
      db('users').count('* as total').first(),
    ]);

    return {
      items,
      pagination: {
        page,
        per_page: limit,
        total_count: count.total,
        total_pages: Math.ceil(count.total / limit),
        has_next: page * limit < count.total,
        has_prev: page > 1,
      },
    };
  }

  async findById(id) {
    const user = await db('users').where({ id }).first().select(
      'id', 'name', 'email', 'role', 'created_at'
    );
    return user || null;
  }

  async create(data) {
    // Check if email already exists
    const existing = await db('users').where({ email: data.email }).first();
    if (existing) {
      throw new AppError('Email already exists', { 
        code: 'DUPLICATE_EMAIL', 
        statusCode: 409 
      });
    }

    const hashedPassword = await bcrypt.hash(data.password, 12);

    const [user] = await db('users').insert({
      name: data.name,
      email: data.email.toLowerCase(),
      password_hash: hashedPassword,
      role: data.role || 'user',
    }).returning(['id', 'name', 'email', 'role', 'created_at']);

    return user;
  }

  async update(id, data) {
    // Don't allow updating these fields through this method:
    const allowedFields = ['name', 'email'];
    const updates = {};

    for (const field of allowedFields) {
      if (data[field] !== undefined) {
        updates[field] = field === 'email' ? data[field].toLowerCase() : data[field];
      }
    }

    if (Object.keys(updates).length === 0) {
      throw new AppError('No valid fields to update', { code: 'NO_UPDATES', statusCode: 400 });
    }

    const [user] = await db('users').where({ id }).update(updates)
      .returning(['id', 'name', 'email', 'role', 'updated_at']);

    return user || null;
  }

  async remove(id) {
    const deleted = await db('users').where({ id }).del();
    return deleted > 0;
  }
}

module.exports = new UserService();
Enter fullscreen mode Exit fullscreen mode

Entry Point & Server Start

// src/index.js — Server creation and startup
const createApp = require('./app');

function normalizePort(val) {
  const port = parseInt(val, 10);
  if (isNaN(port)) return val; // Named pipe
  if (port >= 0) return port;
  return false;
}

const PORT = normalizePort(process.env.PORT || '3000');
const app = createApp();

const server = app.listen(PORT, () => {
  console.log(`🚀 API running on port ${PORT} (${process.env.NODE_ENV})`);
});

// Graceful shutdown:
function shutdown(signal) {
  console.log(`\n${signal} received. Shutting down gracefully...`);

  // Stop accepting new connections
  server.close(() => {
    console.log('HTTP server closed.');
    process.exit(0);
  });

  // Force exit after timeout
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }.unref(), 10000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// Unhandled rejection protection:
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
  if (process.env.NODE_ENV === 'development') process.exit(1);
});

module.exports = { app, server };
Enter fullscreen mode Exit fullscreen mode

What's your favorite Express pattern? What framework do you prefer over Express and why?

Follow @armorbreak for more practical developer guides.

Top comments (0)