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 still the most popular Node.js framework. Here's how to use it right — from hello world to production-ready APIs.

Quick Setup

mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev typescript tsx @types/express @types/node

# package.json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "build": "tsc"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Minimum Viable API

import express from 'express';

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware (order matters!)
app.use(express.json());           // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Form data

// Routes
app.get('/', (req, res) => {
  res.json({ message: 'API is running', version: '1.0.0' });
});

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

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

// Error handler (must have 4 parameters!)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error(`[ERROR] ${err.message}`);
  res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An internal error occurred' } });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Route Organization

src/
  routes/
    users.ts          # /api/users endpoints
    auth.ts           # /api/auth endpoints
    health.ts         # /api/health endpoints
  middleware/
    auth.ts           # Authentication middleware
    validate.ts       # Input validation
    errorHandler.ts   # Global error handler
    rateLimiter.ts    # Rate limiting
  controllers/
    userController.ts # Business logic
  services/
    userService.ts    # Data access & external calls
  utils/
    response.ts       # Standardized response helpers
    errors.ts         # Custom error classes
  types/
    express.d.ts      # Augmented Express types
  index.ts            # App entry point
Enter fullscreen mode Exit fullscreen mode

Route Module Pattern

// src/routes/users.ts
import { Router, Request, Response } from 'express';
import { UserController } from '../controllers/userController.js';

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

router.get('/', userController.list.bind(userController));         // GET /api/users
router.get('/:id', userController.getById.bind(userController));    // GET /api/users/:id
router.post('/', userController.create.bind(userController));       // POST /api/users
router.patch('/:id', userController.update.bind(userController));   // PATCH /api/users/:id
router.delete('/:id', userController.remove.bind(userController));  // DELETE /api/users/:id

export default router;

// src/index.ts (mount routes)
import userRoutes from './routes/users.js';
app.use('/api/users', userRoutes);
Enter fullscreen mode Exit fullscreen mode

Middleware: The Real Power of Express

Built-in Middleware

app.use(express.json({ limit: '1mb' }));       // Body parser (max 1MB)
app.use(express.urlencoded({ extended: false })); // Form data
app.use(express.static('public'));               // Static files
app.use(express.text());                         // Plain text bodies
app.use(express.raw());                          // Raw Buffer bodies
Enter fullscreen mode Exit fullscreen mode

Custom Middleware

// Request logging
function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
  });
  next();
}

// Request ID (traceability)
import { randomUUID } from 'crypto';
function requestId(req: Request, res: Response, next: NextFunction) {
  const id = req.headers['x-request-id'] || randomUUID();
  req.headers['x-request-id'] = id as string;
  res.setHeader('X-Request-ID', id as string);
  next();
}

// Timing
function responseTime(req: Request, res: Response, next: NextFunction) {
  const start = process.hrtime.bigint();
  res.on('finish', () => {
    const ns = Number(process.hrtime.bigint() - start);
    res.setHeader('X-Response-Time', `${(ns / 1e6).toFixed(2)}ms`);
  });
  next();
}

// Compose middleware
app.use(requestId);
app.use(requestLogger);
app.use(responseTime);
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware

import jwt from 'jsonwebtoken';

interface AuthRequest extends Request {
  user?: { id: string; role: string };
}

function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) {
    return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing auth token' } });
  }
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: string };
    req.user = decoded;
    next();
  } catch (err) {
    if (err instanceof jwt.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 auth token' } });
  }
}

// Role-based access
function authorize(...roles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' } });
    }
    next();
  };
}

// Usage:
app.get('/api/users', authenticate, authorize('admin'), userController.list);
Enter fullscreen mode Exit fullscreen mode

Validation Middleware

import { z } from 'zod'; // or use joi/ajv

function validate<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const errors = result.error.issues.map(issue => ({
        field: issue.path.join('.'),
        message: issue.message,
      }));
      return res.status(422).json({ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: errors } });
    }
    req.body = result.data; // Use validated/sanitized data
    next();
  };
}

// Define schemas
const createUserSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string().min(2, 'Name must be at least 2 characters').max(100),
  password: z.string().min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase').regex(/[0-9]/, 'Must contain number'),
  role: z.enum(['admin', 'editor', 'viewer']).optional().default('viewer'),
});

// Usage:
app.post('/api/users', authenticate, authorize('admin'), validate(createUserSchema), userController.create);
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

import rateLimit from 'express-rate-limit';

// Global rate limit
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // Max 100 requests per window
  standardHeaders: true,      // Return rate limit info in headers
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id || req.ip!,
  message: { error: { code: 'RATE_LIMITED', message: 'Too many requests, try again later' } },
}));

// Strict limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,                     // Only 5 login attempts per 15 min
  skipSuccessfulRequests: true, // Reset counter on success
});

app.post('/api/auth/login', authLimiter, authController.login);
Enter fullscreen mode Exit fullscreen mode

Error Handling Architecture

// Custom error classes
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: unknown[]
  ) {
    super(message);
    this.name = 'AppError';
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, 'NOT_FOUND', `${resource} not found`, [{ resource, id }]);
  }
}

class ValidationError extends AppError {
  constructor(details: unknown[]) {
    super(422, 'VALIDATION_ERROR', 'Validation failed', details);
  }
}

class ConflictError extends AppError {
  constructor(message: string) {
    super(409, 'CONFLICT', message);
  }
}

// Global error handler
function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        requestId: req.headers['x-request-id'],
      }
    });
  }

  // Prisma errors (if using Prisma)
  if (err.code === 'P2025') { // Record not found
    return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Record not found' } });
  }
  if (err.code === 'P2002') { // Unique constraint violation
    return res.status(409).json({ error: { code: 'CONFLICT', message: 'Resource already exists' } });
  }

  // Unknown errors (log full, send safe message)
  console.error(`[UNHANDLED] ${err.stack}`);
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An internal error occurred',
      requestId: req.headers['x-request-id'],
    }
  });
}

// Async error wrapper (so you don't need try/catch in every handler)
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
}

// Usage: No need for try/catch!
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  if (!user) throw new NotFoundError('User', req.params.id);
  res.json({ success: true, data: user }));
}));
Enter fullscreen mode Exit fullscreen mode

Performance Tips

// 1. Enable compression (huge impact!)
import compression from 'compression';
app.use(compression()); // 70%+ reduction in response size

// 2. Security headers
import helmet from 'helmet';
app.use(helmet());

// 3. CORS
import cors from 'cors';
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(','),
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// 4. Graceful shutdown
const server = app.listen(PORT);

process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
  // Force close after 30 seconds
  setTimeout(() => process.exit(1), 30_000);
});

// 5. Trust proxy (if behind Nginx/Cloudflare)
app.set('trust proxy', 1);

// 6. Disable ETag for dynamic content (save CPU)
app.set('etag', false);

// 7. Connection keep-alive
server.keepAliveTimeout = 61_000; // Slightly > Cloudflare's 60s timeout
server.headersTimeout = 62_000;
Enter fullscreen mode Exit fullscreen mode

Quick Checklist Before Shipping

□ All routes behind authentication where needed?
□ Input validated with schema (zod/joi)?
□ Rate limiting on all endpoints?
□ Error handler catches all unhandled errors?
□ CORS configured (not wildcard)?
□ Security headers (helmet)?
□ Compression enabled?
□ Graceful shutdown handler?
□ Health check endpoint?
□ Request logging?
□ Request IDs for traceability?
□ Environment variables validated on startup?
□ No sensitive data in responses or logs?
□ Database connection pooling configured?
□ Process manager (PM2/systemd) for production?
Enter fullscreen mode Exit fullscreen mode

What's your Express middleware must-have? What's missing from this guide?

Follow @armorbreak for more practical developer guides.

Top comments (0)