BlogDevelopment
Development

Building Production-Ready REST APIs with Node.js

Authentication, rate limiting, input validation, error handling, and structured logging — the checklist every Node.js backend needs before going live.

R
Rohan Gupta
Senior Software Engineer
·Feb 20, 2025·12 min read

Most Node.js REST API tutorials show you how to get an endpoint returning JSON. Almost none of them show you how to make it production-ready. This guide covers the non-negotiable items: authentication, rate limiting, input validation, error handling, and structured logging — the things that get you paged at 3 AM if you skip them.

Project Structure

bash
src/
├── config/          # Environment config, database connections
├── middleware/      # Auth, rate limiting, error handler, logger
├── routes/          # Route definitions (thin — delegate to controllers)
├── controllers/     # Request/response handling
├── services/        # Business logic (no Express imports here)
├── models/          # Database models (Prisma schema / Mongoose models)
├── validators/      # Zod / Joi schemas for input validation
├── utils/           # Pure utility functions
└── app.ts           # Express app setup (no server.listen here)
server.ts            # server.listen — keeps app testable

Authentication with JWT

Use short-lived access tokens (15 minutes) paired with refresh tokens stored in httpOnly cookies. Never store JWTs in localStorage — XSS attacks can exfiltrate them.

typescript
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

export function signAccessToken(userId: string): string {
  return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, {
    expiresIn: '15m',
    algorithm: 'RS256',  // Asymmetric — public key can verify without exposing secret
  });
}

export function authenticateToken(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers['authorization'];
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

  if (!token) return res.status(401).json({ error: 'Access token required' });

  try {
    const payload = jwt.verify(token, ACCESS_TOKEN_SECRET) as { sub: string };
    req.userId = payload.sub;
    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

Input Validation with Zod

Validate every incoming request body. Trust nothing from the client. Zod provides TypeScript-native validation with excellent error messages:

typescript
import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email().toLowerCase(),
  password: z.string().min(8).max(128),
  name: z.string().min(1).max(100).trim(),
  role: z.enum(['user', 'admin']).default('user'),
});

// Validation middleware
export function validate(schema: z.ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.flatten().fieldErrors,
      });
    }
    req.body = result.data;  // Replace with parsed (sanitised) data
    next();
  };
}

// Usage in route
router.post('/users', validate(CreateUserSchema), createUserController);

Rate Limiting

typescript
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

// Global rate limit
export const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  limit: 100,                  // 100 requests per window
  standardHeaders: 'draft-7',
  store: new RedisStore({ client: redis }),
});

// Strict limit for auth endpoints
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
  store: new RedisStore({ client: redis, prefix: 'rl:auth:' }),
});

// Apply in app.ts
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);

Structured Error Handling

A consistent error format makes debugging and client-side error handling dramatically easier. Define a base error class and a global error handler:

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

// Global error handler — must be the LAST middleware
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
    });
  }

  // Unexpected errors — log full error, send generic message
  logger.error({ err, req: { method: req.method, url: req.url } });

  res.status(500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message,
  });
}

Structured Logging with Pino

typescript
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: process.env.NODE_ENV !== 'production'
    ? { target: 'pino-pretty' }
    : undefined,
  // Production: output NDJSON — ship to CloudWatch / Loki / Datadog
});

// Request logger middleware
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration: Date.now() - start,
      ip: req.ip,
      userId: req.userId,  // From auth middleware
    });
  });
  next();
}

Production Checklist

  • Helmet.js: sets security headers (CSP, HSTS, X-Frame-Options) in one line
  • CORS: configure explicitly — never use cors() with no options in production
  • Compression: gzip responses with the compression middleware
  • Health endpoint: GET /health returns 200 with DB connectivity check — used by load balancers
  • Graceful shutdown: handle SIGTERM, close server and DB connections cleanly before exit
  • Environment variables: use dotenv for local dev, never commit .env files
  • Security: run npm audit weekly; use Snyk or GitHub Dependabot for automated alerts
  • Load testing: run k6 or Artillery before launch — find your breaking point before users do
Category:Development