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. 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          # Config loader (env-based)
│   │   └── database.js       # DB config
│   ├── routes/
│   │   ├── index.js          # Route aggregator
│   │   ├── users.js          # User routes
│   │   ├── posts.js          # Post routes
│   │   └── health.js         # Health check route
│   ├── controllers/          # Route handlers (business logic)
│   │   ├── userController.js
│   │   └── postController.js
│   ├── middleware/
│   │   ├── auth.js           # Authentication
│   │   ├── validate.js       # Request validation
│   │   ├── errorHandler.js   # Global error handler
│   │   └── rateLimit.js      # Rate limiting
│   ├── models/
│   │   ├── User.js           # Data model/schema
│   │   └── Post.js
│   ├── services/
│   │   ├── userService.js    # Business logic (separated from HTTP!)
│   │   └── postService.js
│   ├── utils/
│   │   ├── response.js       # Standardized response helper
│   │   ├── logger.js         # Winston/Pino logger
│   │   └── asyncHandler.js   # Try-catch wrapper
│   └── db/
│       ├── connection.js     # DB connection pool
│       └── queries.js        # SQL queries
├── tests/
│   ├── unit/
│   └── integration/
├── .env.example
├── package.json
├── tsconfig.json             # If using TypeScript
└── Dockerfile
Enter fullscreen mode Exit fullscreen mode

App Setup (app.js — No Server Start!)

// app.js — Configure Express, DON'T call app.listen()
// This makes it testable and reusable

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

// Create app instance
const app = express();

// === Security Middleware (order matters!) ===
app.use(helmet());                    // Security headers
app.use(cors({
  origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// === Body Parsing ===
app.use(express.json({ limit: '1mb' })); // JSON body
app.use(express.urlencoded({ extended: true })); // Form data

// === 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();
});

// === Logging ===
const morgan = require('morgan');
app.use(morgan('combined', {
  immediate: false,
  skip: () => process.env.NODE_ENV === 'test',
}));

// === Rate Limiting ===
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests' },
});
app.use('/api/', limiter);

// Stricter auth limits:
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many login attempts' },
});

// === Routes ===
app.use('/api/health', require('./routes/health'));
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));

// === Error Handling ===
// 404 handler (must be AFTER all routes)
app.use((req, res) => {
  res.status(404).json({
    error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` }
  });
});

// Global error handler (must have 4 params!)
app.use((err, req, res, _next) => {
  const status = err.status || 500;

  // Don't expose stack traces in production
  const message = process.env.NODE_ENV === 'production'
    ? 'An unexpected error occurred'
    : err.message;

  logger.error(`[Error] ${req.id}`, { error: err.message, stack: err.stack });

  res.status(status).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message,
      ...(process.env.NODE_ENV !== 'production' && { details: err.details }),
      requestId: req.id,
    }
  });
});

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

Async Handler Wrapper

// utils/asyncHandler.js — Eliminates try/catch boilerplate in every route!

// ❌ Without wrapper (repetitive):
app.get('/users', async (req, res, next) => {
  try {
    const users = await userService.getAll();
    res.json({ data: users });
  } catch (err) {
    next(err); // Must pass to error handler
  }
});

// ✅ With wrapper:
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Usage:
app.get('/users', asyncHandler(async (req, res) => {
  const users = await userService.getAll();
  res.json({ data: users }));
});

// Even better — with typed errors:
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
  }
}

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);
  // AppError instances are handled by global error handler automatically!
  // Other errors (TypeError, etc.) become 500 Internal Server Error

// Usage in controllers:
const getUser = asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
  res.json({ data: user });
});
Enter fullscreen mode Exit fullscreen mode

Route Organization

// routes/users.js
const express = require('express');
const router = express.Router();
const { authenticate, authorize } = require('../middleware/auth');
const { validate } = require('../middleware/validate');
const { createUserSchema, updateUserSchema } = require('../schemas/userSchemas');
const { listUsers, getUser, createUser, updateUser, deleteUser } = require('../controllers/userController');

// All routes prefixed with /api/users (where mounted in app.js)

// Public routes
router.post('/', validate(createUserSchema), createUser);

// Protected routes (require authentication)
router.use(authenticate);          // All routes below need auth

router.get('/', listUsers);
router.get('/me', getProfile);     // Current user's profile
router.get('/:id', getUser);

// Admin-only routes
router.patch('/:id', authorize('admin'), validate(updateUserSchema), updateUser);
router.delete('/:id', authorize('admin'), deleteUser);

module.exports = router;

// Controller (controllers/userController.js):
const userService = require('../services/userService');

exports.listUsers = asyncHandler(async (req, res) => {
  const { page = 1, limit = 20, role, sort } = req.query;
  const result = await userService.list({
    page: Math.max(1, parseInt(page)),
    limit: Math.min(100, parseInt(limit)),
    role,
    sort,
  });
  res.json({
    data: result.items,
    meta: { page: result.page, limit: result.limit, total: result.total },
  });
});

exports.getUser = asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
  res.json({ data: user });
});
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware

// middleware/auth.js
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const header = req.headers.authorization;

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

  const token = header.slice(7);

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'], // Prevent algorithm confusion attack
      issuer: 'my-app',
    });

    req.user = payload; // Attach user to request
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: { code: 'TOKEN_EXPIRED', message: 'Token has expired' }
      });
    }
    return res.status(401).json({
      error: { code: 'INVALID_TOKEN', message: 'Invalid token' }
    });
  }
}

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

// Optional authentication (sets user if token present, but doesn't require it):
function optionalAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) return next();

  try {
    req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
  } catch (_) { /* Ignore invalid tokens */ }
  next();
}
Enter fullscreen mode Exit fullscreen mode

Database Integration

// db/connection.js — Connection pool with proper lifecycle
const { Pool } = require('pg'); // PostgreSQL example

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,                  // Max connections
  idleTimeoutMillis: 30000, // Close idle connections after 30s
  connectionTimeoutMillis: 2000, // Fail fast if can't connect
});

// Graceful shutdown
process.on('SIGTERM', () => pool.end().then(() => process.exit(0)));
process.on('SIGINT', () => pool.end().then(() => process.exit(0)));

// Query helper with logging:
async function query(text, params) {
  const start = Date.now();
  const res = await pool.query(text, params);
  const duration = Date.now() - start;
  if (duration > 1000) {
    logger.warn(`Slow query (${duration}ms): ${text.slice(0, 100)}`);
  }
  return res;
}

module.exports = { pool, query };

// Service layer (services/userService.js):
const { query } = require('../db/connection');

exports.list = async ({ page, limit, role, sort }) => {
  let sql = 'SELECT id, name, email, role, created_at FROM users WHERE 1=1';
  const params = [];
  let paramIndex = 1;

  if (role) {
    sql += ` AND role = $${paramIndex++}`;
    params.push(role);
  }

  const countSql = `SELECT COUNT(*) FROM users WHERE 1=1` + 
    (role ? ` AND role = $1` : '');

  const orderClause = sort ? ` ORDER BY ${sort.replace(/[^a-z_]/gi, '')}` : '';
  sql += orderClause + ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
  params.push(limit, (page - 1) * limit);

  const [countResult, itemsResult] = await Promise.all([
    query(countSql, role ? [role] : []),
    query(sql, params),
  ]);

  return {
    items: itemsResult.rows,
    total: parseInt(countResult.rows[0].count),
    page,
    limit,
  };
};
Enter fullscreen mode Exit fullscreen mode

Entry Point & Server Lifecycle

// index.js — ONLY handles server startup
const app = require('./app');
const config = require('./config');

let server;

function startServer() {
  server = app.listen(config.port, () => {
    console.log(`🚀 API running on port ${config.port} (${config.nodeEnv})`);
  });

  server.setTimeout(120000); // 2 min timeout for long requests
  server.keepAliveTimeout = 65000; // Slightly less than load balancer timeout

  // Handle graceful shutdown
  const shutdown = (signal) => {
    console.log(`${signal} received. Shutting down gracefully...`);
    server.close(() => {
      console.log('HTTP server closed.');
      process.exit(0);
    });
    // Force exit after 10 seconds
    setTimeout(() => process.exit(1), 10000);
  };

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

startServer();

// For testing: module.exports = { app, startServer };
Enter fullscreen mode Exit fullscreen mode

What's your favorite Express pattern? What do you wish you knew when you started building APIs?

Follow @armorbreak for more practical developer guides.

Top comments (0)