Monday, August 4, 2025
-
By, Kanad Shee
Node.js with Express, TypeScript, Winston Logger, and more
Setting up a robust backend from scratch can feel overwhelmingβespecially when aiming for scalability, strong typing, and proper logging from the get-go. But with Node.js + Express + TypeScript, itβs entirely possible to craft a production-ready backend with clarity and control.
This project demonstrates how to build a modular, typed, and scalable backend using:
src/
βββ config/ # Configuration files
β βββ config.ts # Environment configuration
βββ middleware/ # Custom middleware
β βββ auth.ts # Authentication middleware
β βββ errorHandler.ts # Global error handler
β βββ notFoundHandler.ts # 404 handler
β βββ requestLogger.ts # Request logging
βββ routes/ # Route definitions
β βββ api.ts # Main API routes
β βββ health.ts # Health check routes
β βββ users.ts # User routes
βββ utils/ # Utility functions
β βββ AppError.ts # Custom error classes
β βββ logger.ts # Winston logger setup
βββ app.ts # Express app configuration
βββ index.ts # Application entry point
The application follows a clean, modular architecture with:
Lets get into deep in all files:
npm init -y
Here I have added all the necessary dependecies and dev dependencies needed for our project and setup:
// File name: package.json
{
"name": "express-typescript-app",
"version": "1.0.0",
"description": "Production-grade Express.js application with TypeScript",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"build:watch": "tsc --watch",
"clean": "rimraf dist",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier --write src/**/*.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"typecheck": "tsc --noEmit",
"prepare": "husky install"
},
"keywords": ["express", "typescript", "node", "api"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.19.2",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"dotenv": "^16.4.5",
"express-rate-limit": "^7.2.0",
"express-validator": "^7.0.1",
"winston": "^3.13.0",
"express-async-errors": "^3.1.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.12.7",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/morgan": "^1.9.9",
"@types/jest": "^29.5.12",
"@types/supertest": "^6.0.2",
"typescript": "^5.4.5",
"tsx": "^4.7.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"supertest": "^7.0.0",
"eslint": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"prettier": "^3.2.5",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"rimraf": "^5.0.5",
"nodemon": "^3.1.0"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"lint-staged": {
"*.{ts,js}": ["eslint --fix", "prettier --write"]
}
}
npm install
npx tsc --init
This will create a tsconfig.json file for us:
And now replace that file with these options:
// File Name: tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "CommonJS",
"moduleResolution": "node",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"noEmitOnError": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@/controllers/*": ["src/controllers/*"],
"@/middleware/*": ["src/middleware/*"],
"@/routes/*": ["src/routes/*"],
"@/services/*": ["src/services/*"],
"@/types/*": ["src/types/*"],
"@/utils/*": ["src/utils/*"],
"@/config/*": ["src/config/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
eslint
and prettier
config for better linting:eslint.json
file and paste following configuration:{
"env": {
"node": true,
"es2022": true,
"jest": true
},
"extends": [
"eslint:recommended",
"@typescript-eslint/recommended",
"@typescript-eslint/recommended-requiring-type-checking"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"no-console": "warn",
"prefer-const": "error",
"no-var": "error"
},
"ignorePatterns": ["dist/", "node_modules/", "*.js"]
}
.prettierrc
:{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
jest.config.ts
:// File Name: jest.config.ts
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
transform: {
"^.+\\.ts$": "ts-jest",
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/*.test.ts",
"!src/**/*.spec.ts",
],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
moduleNameMapping: {
"^@/(.*)$": "<rootDir>/src/$1",
},
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
};
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Testing
.jest/
# Temporary files
temp/
tmp/
# Environment Configuration
NODE_ENV=development
PORT=3000
# Security
API_KEY=your-super-secret-api-key-here
# Logging
LOG_LEVEL=info
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
# Rate Limiting
RATE_LIMIT_MAX=100
# Database (optional)
DATABASE_URL=postgresql://username:password@localhost:5432/myapp
# Redis (optional)
REDIS_URL=redis://localhost:6379
# JWT (optional)
JWT_SECRET=your-jwt-secret-key-here
src > config
, create a file named: config.ts
// File Name: config.ts
// Environment validation without zod for simplicity
interface EnvConfig {
NODE_ENV: "development" | "production" | "test";
PORT: number;
API_KEY: string;
LOG_LEVEL: "error" | "warn" | "info" | "debug";
CORS_ORIGINS: string[];
RATE_LIMIT_MAX: number;
DATABASE_URL?: string | undefined;
REDIS_URL?: string | undefined;
JWT_SECRET?: string | undefined;
}
// Validate environment variables
const validateEnv = (): EnvConfig => {
const requiredVars = ["API_KEY"];
const missingVars = requiredVars.filter((varName) => !process.env[varName]);
if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables: ${missingVars.join(", ")}`,
);
}
return {
NODE_ENV: (process.env.NODE_ENV as EnvConfig["NODE_ENV"]) || "development",
PORT: parseInt(process.env.PORT || "3000", 10),
API_KEY: process.env.API_KEY!,
LOG_LEVEL: (process.env.LOG_LEVEL as EnvConfig["LOG_LEVEL"]) || "info",
CORS_ORIGINS: (process.env.CORS_ORIGINS || "http://localhost:3000")
.split(",")
.map((origin) => origin.trim()),
RATE_LIMIT_MAX: parseInt(process.env.RATE_LIMIT_MAX || "100", 10),
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
JWT_SECRET: process.env.JWT_SECRET,
};
};
const env = validateEnv();
export const config = {
nodeEnv: env.NODE_ENV,
port: env.PORT,
apiKey: env.API_KEY,
logLevel: env.LOG_LEVEL,
corsOrigins: env.CORS_ORIGINS,
rateLimitMax: env.RATE_LIMIT_MAX,
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
jwtSecret: env.JWT_SECRET,
} as const;
export type Config = typeof config;
utils
:AppError.ts
logger.ts
Lets create the AppError.ts
file:
// File Name: AppError.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
public readonly timestamp: string;
constructor(
message: string,
statusCode: number = 500,
isOperational: boolean = true,
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.timestamp = new Date().toISOString();
// Maintains proper stack trace for where our error was thrown
Error.captureStackTrace(this, this.constructor);
// Set the prototype explicitly
Object.setPrototypeOf(this, AppError.prototype);
}
}
// Predefined error classes for common HTTP errors
export class BadRequestError extends AppError {
constructor(message: string = "Bad Request") {
super(message, 400);
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = "Unauthorized") {
super(message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message: string = "Forbidden") {
super(message, 403);
}
}
export class NotFoundError extends AppError {
constructor(message: string = "Not Found") {
super(message, 404);
}
}
export class ConflictError extends AppError {
constructor(message: string = "Conflict") {
super(message, 409);
}
}
export class ValidationError extends AppError {
constructor(message: string = "Validation Error") {
super(message, 422);
}
}
export class TooManyRequestsError extends AppError {
constructor(message: string = "Too Many Requests") {
super(message, 429);
}
}
export class InternalServerError extends AppError {
constructor(message: string = "Internal Server Error") {
super(message, 500);
}
}
export class ServiceUnavailableError extends AppError {
constructor(message: string = "Service Unavailable") {
super(message, 503);
}
}
logger.ts
file:// File Name: logger.ts
import winston from "winston";
import { config } from "@/config/config";
// Custom log levels
const levels = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
// Custom colors for log levels
const colors = {
error: "red",
warn: "yellow",
info: "green",
debug: "blue",
};
winston.addColors(colors);
// Create logger instance
export const logger = winston.createLogger({
level: config.logLevel,
levels,
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.errors({ stack: true }),
winston.format.json(),
winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
let log = `${timestamp} [${level.toUpperCase()}]: ${message}`;
if (stack) {
log += `\n${stack}`;
}
if (Object.keys(meta).length > 0) {
log += `\n${JSON.stringify(meta, null, 2)}`;
}
return log;
}),
),
transports: [
// Console transport with colors for development
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.simple(),
),
}),
],
exceptionHandlers: [new winston.transports.Console()],
rejectionHandlers: [new winston.transports.Console()],
exitOnError: false,
});
// Add file transports for production
if (config.nodeEnv === "production") {
logger.add(
new winston.transports.File({
filename: "logs/error.log",
level: "error",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
}),
);
logger.add(
new winston.transports.File({
filename: "logs/combined.log",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
}),
);
}
// Create a stream object for Morgan HTTP logging
export const stream = {
write: (message: string): void => {
logger.info(message.trim());
},
};
auth.ts
errorHandler.ts
health.ts
notFoundHandler.ts
requestLogger.ts
First create the auth.ts
file:
// File Name: auth.ts
import { Request, Response, NextFunction } from "express";
import { UnauthorizedError } from "@/utils/AppError";
import { config } from "@/config/config";
import { logger } from "@/utils/logger";
export const validateApiKey = (
req: Request,
res: Response,
next: NextFunction,
): void => {
try {
const apiKey =
req.headers["x-api-key"] ||
req.headers["authorization"]?.replace("Bearer ", "");
if (!apiKey) {
logger.warn("API key missing", {
requestId: req.requestId,
ip: req.ip,
url: req.originalUrl,
});
throw new UnauthorizedError("API key is required");
}
if (apiKey !== config.apiKey) {
logger.warn("Invalid API key attempted", {
requestId: req.requestId,
ip: req.ip,
url: req.originalUrl,
providedKey:
typeof apiKey === "string"
? apiKey.substring(0, 8) + "..."
: "invalid",
});
throw new UnauthorizedError("Invalid API key");
}
logger.debug("API key validated successfully", {
requestId: req.requestId,
ip: req.ip,
});
next();
} catch (error) {
next(error);
}
};
// Optional: JWT validation middleware (if using JWT tokens)
export const validateJWT = (
req: Request,
res: Response,
next: NextFunction,
): void => {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new UnauthorizedError("JWT token is required");
}
// Add JWT validation logic here
// Example: jwt.verify(token, config.jwtSecret)
next();
} catch (error) {
next(error);
}
};
// Rate limiting per user/API key
export const createUserRateLimit = (windowMs: number, max: number) => {
const requests = new Map<string, { count: number; resetTime: number }>();
return (req: Request, res: Response, next: NextFunction): void => {
const identifier = (req.headers["x-api-key"] as string) || req.ip;
const now = Date.now();
const windowStart = now - windowMs;
// Clean up old entries
for (const [key, value] of requests.entries()) {
if (value.resetTime < windowStart) {
requests.delete(key);
}
}
// Get or create request counter
let requestData = requests.get(identifier as string);
if (!requestData || requestData.resetTime < windowStart) {
requestData = { count: 0, resetTime: now + windowMs };
requests.set(identifier as string, requestData);
}
requestData.count++;
if (requestData.count > max) {
logger.warn("Rate limit exceeded", {
identifier: String(identifier).substring(0, 8) + "...",
count: requestData.count,
max,
requestId: req.requestId,
});
res.status(429).json({
error: {
message: "Rate limit exceeded",
retryAfter: Math.ceil((requestData.resetTime - now) / 1000),
},
});
return;
}
next();
};
};
errorHandler.ts
file:// errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError";
import { logger } from "@/utils/logger";
import { config } from "@/config/config";
interface ErrorResponse {
error: {
message: string;
status: string;
statusCode: number;
timestamp: string;
path: string;
method: string;
stack?: string;
requestId?: string;
};
}
export const globalErrorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
): void => {
let error = err;
// Convert non-AppError errors to AppError
if (!(error instanceof AppError)) {
// Handle specific error types
if (error.name === "ValidationError") {
error = new AppError("Validation Error", 400);
} else if (error.name === "CastError") {
error = new AppError("Invalid data format", 400);
} else if (error.name === "MongoError" && (error as any).code === 11000) {
error = new AppError("Duplicate field value", 409);
} else if (error.name === "JsonWebTokenError") {
error = new AppError("Invalid token", 401);
} else if (error.name === "TokenExpiredError") {
error = new AppError("Token expired", 401);
} else {
// Generic server error
error = new AppError("Something went wrong", 500, false);
}
}
const appError = error as AppError;
const statusCode = appError.statusCode || 500;
// Log error details
const logData = {
error: {
message: appError.message,
stack: appError.stack,
statusCode,
url: req.originalUrl,
method: req.method,
ip: req.ip,
userAgent: req.get("User-Agent"),
timestamp: new Date().toISOString(),
},
};
if (statusCode >= 500) {
logger.error("Server Error:", logData);
} else {
logger.warn("Client Error:", logData);
}
// Prepare error response
const errorResponse: ErrorResponse = {
error: {
message: appError.message,
status: statusCode >= 500 ? "error" : "fail",
statusCode,
timestamp: appError.timestamp || new Date().toISOString(),
path: req.originalUrl,
method: req.method,
},
};
// Include stack trace in development
if (config.nodeEnv === "development") {
errorResponse.error.stack = appError.stack as string;
}
// Add request ID if available
if (req.headers["x-request-id"]) {
errorResponse.error.requestId = req.headers["x-request-id"] as string;
}
res.status(statusCode).json(errorResponse);
};
health.ts
file:// File Name: health.ts
import { Router, Request, Response } from "express";
import { config } from "@/config/config";
const router = Router();
interface HealthResponse {
status: "ok" | "error";
timestamp: string;
uptime: number;
environment: string;
version: string;
memory: {
used: number;
free: number;
total: number;
usage: string;
};
system: {
platform: string;
arch: string;
nodeVersion: string;
};
services?: {
database?: "connected" | "disconnected" | "error";
redis?: "connected" | "disconnected" | "error";
};
}
// Basic health check
router.get("/", (req: Request, res: Response): void => {
const memUsage = process.memoryUsage();
const totalMemory = memUsage.heapTotal;
const usedMemory = memUsage.heapUsed;
const freeMemory = totalMemory - usedMemory;
const healthData: HealthResponse = {
status: "ok",
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
environment: config.nodeEnv,
version: process.env.npm_package_version || "1.0.0",
memory: {
used: Math.round(usedMemory / 1024 / 1024), // MB
free: Math.round(freeMemory / 1024 / 1024), // MB
total: Math.round(totalMemory / 1024 / 1024), // MB
usage: `${Math.round((usedMemory / totalMemory) * 100)}%`,
},
system: {
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
},
};
res.status(200).json(healthData);
});
// Detailed health check with services
router.get("/detailed", async (req: Request, res: Response): Promise<void> => {
const memUsage = process.memoryUsage();
const totalMemory = memUsage.heapTotal;
const usedMemory = memUsage.heapUsed;
const freeMemory = totalMemory - usedMemory;
const healthData: HealthResponse = {
status: "ok",
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
environment: config.nodeEnv,
version: process.env.npm_package_version || "1.0.0",
memory: {
used: Math.round(usedMemory / 1024 / 1024), // MB
free: Math.round(freeMemory / 1024 / 1024), // MB
total: Math.round(totalMemory / 1024 / 1024), // MB
usage: `${Math.round((usedMemory / totalMemory) * 100)}%`,
},
system: {
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
},
services: {},
};
// Check database connection (if configured)
if (config.databaseUrl) {
try {
// Add your database health check logic here
// Example: await database.ping()
healthData.services!.database = "connected";
} catch (error) {
healthData.services!.database = "error";
healthData.status = "error";
}
}
// Check Redis connection (if configured)
if (config.redisUrl) {
try {
// Add your Redis health check logic here
// Example: await redis.ping()
healthData.services!.redis = "connected";
} catch (error) {
healthData.services!.redis = "error";
healthData.status = "error";
}
}
const statusCode = healthData.status === "ok" ? 200 : 503;
res.status(statusCode).json(healthData);
});
// Readiness probe (for Kubernetes)
router.get("/ready", (req: Request, res: Response): void => {
// Add any readiness checks here
// Example: check if database migrations are complete
res.status(200).json({
status: "ready",
timestamp: new Date().toISOString(),
});
});
// Liveness probe (for Kubernetes)
router.get("/live", (req: Request, res: Response): void => {
res.status(200).json({
status: "alive",
timestamp: new Date().toISOString(),
});
});
export default router;
notFoundHandler.ts
file:// File Name: notFoundHandler.ts
import { Request, Response, NextFunction } from "express";
import { NotFoundError } from "@/utils/AppError";
export const notFoundHandler = (
req: Request,
res: Response,
next: NextFunction,
): void => {
const message = `Route ${req.originalUrl} not found`;
next(new NotFoundError(message));
};
requestLogger.ts
to log each incoming request:// requestLogger.ts
import { Request, Response, NextFunction } from "express";
import crypto from "crypto";
import { logger } from "@/utils/logger";
// Extend Request interface to include requestId
declare global {
namespace Express {
interface Request {
requestId: string;
startTime: number;
}
}
}
export const requestLogger = (
req: Request,
res: Response,
next: NextFunction,
): void => {
// Generate unique request ID
req.requestId =
(req.headers["x-request-id"] as string) || crypto.randomUUID();
req.startTime = Date.now();
// Set request ID in response header
res.setHeader("x-request-id", req.requestId);
// Log request details
logger.info("Incoming Request", {
requestId: req.requestId,
method: req.method,
url: req.originalUrl,
ip: req.ip,
userAgent: req.get("User-Agent"),
timestamp: new Date().toISOString(),
});
// Override res.end to log response details
const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any): any {
const responseTime = Date.now() - req.startTime;
logger.info("Outgoing Response", {
requestId: req.requestId,
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
responseTime: `${responseTime}ms`,
contentLength: res.get("content-length") || 0,
timestamp: new Date().toISOString(),
});
originalEnd.call(this, chunk, encoding);
};
next();
};
tests
:setup.ts
inside tests
directory inside src
:// File Name: setup.ts
// Test setup file
import "dotenv/config";
// Set test environment
process.env.NODE_ENV = "test";
process.env.API_KEY = "test-api-key";
process.env.LOG_LEVEL = "error"; // Suppress logs during tests
// Global test timeout
jest.setTimeout(10000);
// Mock console methods to reduce test noise
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
routes
folder:βββ routes/ # Route definitions
β βββ api.ts # Main API routes
β βββ health.ts # Health check routes
β βββ users.ts # User routes
users.ts
file:// File name: users.ts
import { Router, Request, Response, NextFunction } from "express";
import { body, param, query, validationResult } from "express-validator";
import { ValidationError, NotFoundError } from "@/utils/AppError";
import { logger } from "@/utils/logger";
const router = Router();
// Mock user data (replace with actual database)
interface User {
id: string;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
let users: User[] = [
{
id: "1",
name: "John Doe",
email: "john@example.com",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "Jane Smith",
email: "jane@example.com",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
// Validation middleware
const validateUser = [
body("name")
.trim()
.isLength({ min: 2, max: 50 })
.withMessage("Name must be between 2 and 50 characters"),
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email"),
];
const validateUserId = [
param("id").isLength({ min: 1 }).withMessage("User ID is required"),
];
const validateQuery = [
query("page")
.optional()
.isInt({ min: 1 })
.withMessage("Page must be a positive integer"),
query("limit")
.optional()
.isInt({ min: 1, max: 100 })
.withMessage("Limit must be between 1 and 100"),
query("search")
.optional()
.trim()
.isLength({ min: 1 })
.withMessage("Search term cannot be empty"),
];
// Validation error handler
const handleValidationErrors = (
req: Request,
res: Response,
next: NextFunction,
): void => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorMessages = errors.array().map((error) => error.msg);
throw new ValidationError(`Validation failed: ${errorMessages.join(", ")}`);
}
next();
};
// GET /api/users - Get all users with pagination and search
router.get(
"/",
validateQuery,
handleValidationErrors,
(req: Request, res: Response): void => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const search = req.query.search as string;
let filteredUsers = users;
// Apply search filter
if (search) {
filteredUsers = users.filter(
(user) =>
user.name.toLowerCase().includes(search.toLowerCase()) ||
user.email.toLowerCase().includes(search.toLowerCase()),
);
}
// Calculate pagination
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
logger.info("Users retrieved", {
requestId: req.requestId,
total: filteredUsers.length,
page,
limit,
search: search || null,
});
res.status(200).json({
data: paginatedUsers,
pagination: {
page,
limit,
total: filteredUsers.length,
pages: Math.ceil(filteredUsers.length / limit),
hasNext: endIndex < filteredUsers.length,
hasPrev: page > 1,
},
search: search || null,
});
},
);
// GET /api/users/:id - Get user by ID
router.get(
"/:id",
validateUserId,
handleValidationErrors,
(req: Request, res: Response): void => {
const { id } = req.params;
const user = users.find((u) => u.id === id);
if (!user) {
throw new NotFoundError(`User with ID ${id} not found`);
}
logger.info("User retrieved", {
requestId: req.requestId,
userId: id,
});
res.status(200).json({
data: user,
});
},
);
// POST /api/users - Create new user
router.post(
"/",
validateUser,
handleValidationErrors,
(req: Request, res: Response): void => {
const { name, email } = req.body;
// Check if email already exists
const existingUser = users.find((u) => u.email === email);
if (existingUser) {
throw new ValidationError("Email already exists");
}
const newUser: User = {
id: (users.length + 1).toString(),
name,
email,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
users.push(newUser);
logger.info("User created", {
requestId: req.requestId,
userId: newUser.id,
email: newUser.email,
});
res.status(201).json({
message: "User created successfully",
data: newUser,
});
},
);
// PUT /api/users/:id - Update user
router.put(
"/:id",
validateUserId,
validateUser,
handleValidationErrors,
(req: Request, res: Response): void => {
const { id } = req.params;
const { name, email } = req.body;
const userIndex = users.findIndex((u) => u.id === id);
if (userIndex === -1) {
throw new NotFoundError(`User with ID ${id} not found`);
}
// Check if email already exists (excluding current user)
const existingUser = users.find((u) => u.email === email && u.id !== id);
if (existingUser) {
throw new ValidationError("Email already exists");
}
const existing = users[userIndex];
if (!existing) {
throw new NotFoundError(`User with ID ${id} not found`);
}
users[userIndex] = {
id: existing.id,
name,
email,
createdAt: existing.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
logger.info("User updated", {
requestId: req.requestId,
userId: id,
email,
});
res.status(200).json({
message: "User updated successfully",
data: users[userIndex],
});
},
);
// DELETE /api/users/:id - Delete user
router.delete(
"/:id",
validateUserId,
handleValidationErrors,
(req: Request, res: Response): void => {
const { id } = req.params;
const userIndex = users.findIndex((u) => u.id === id);
if (userIndex === -1) {
throw new NotFoundError(`User with ID ${id} not found`);
}
const deletedUser = users.splice(userIndex, 1)[0];
logger.info("User deleted", {
requestId: req.requestId,
userId: id,
email: deletedUser?.email,
});
res.status(200).json({
message: "User deleted successfully",
data: deletedUser,
});
},
);
export default router;
health.ts
file:// File name: health.ts
import { Router, Request, Response } from "express";
import { config } from "@/config/config";
const router = Router();
interface HealthResponse {
status: "ok" | "error";
timestamp: string;
uptime: number;
environment: string;
version: string;
memory: {
used: number;
free: number;
total: number;
usage: string;
};
system: {
platform: string;
arch: string;
nodeVersion: string;
};
services?: {
database?: "connected" | "disconnected" | "error";
redis?: "connected" | "disconnected" | "error";
};
}
// Basic health check
router.get("/", (req: Request, res: Response): void => {
const memUsage = process.memoryUsage();
const totalMemory = memUsage.heapTotal;
const usedMemory = memUsage.heapUsed;
const freeMemory = totalMemory - usedMemory;
const healthData: HealthResponse = {
status: "ok",
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
environment: config.nodeEnv,
version: process.env.npm_package_version || "1.0.0",
memory: {
used: Math.round(usedMemory / 1024 / 1024), // MB
free: Math.round(freeMemory / 1024 / 1024), // MB
total: Math.round(totalMemory / 1024 / 1024), // MB
usage: `${Math.round((usedMemory / totalMemory) * 100)}%`,
},
system: {
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
},
};
res.status(200).json(healthData);
});
// Detailed health check with services
router.get("/detailed", async (req: Request, res: Response): Promise<void> => {
const memUsage = process.memoryUsage();
const totalMemory = memUsage.heapTotal;
const usedMemory = memUsage.heapUsed;
const freeMemory = totalMemory - usedMemory;
const healthData: HealthResponse = {
status: "ok",
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
environment: config.nodeEnv,
version: process.env.npm_package_version || "1.0.0",
memory: {
used: Math.round(usedMemory / 1024 / 1024), // MB
free: Math.round(freeMemory / 1024 / 1024), // MB
total: Math.round(totalMemory / 1024 / 1024), // MB
usage: `${Math.round((usedMemory / totalMemory) * 100)}%`,
},
system: {
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
},
services: {},
};
// Check database connection (if configured)
if (config.databaseUrl) {
try {
// Add your database health check logic here
// Example: await database.ping()
healthData.services!.database = "connected";
} catch (error) {
healthData.services!.database = "error";
healthData.status = "error";
}
}
// Check Redis connection (if configured)
if (config.redisUrl) {
try {
// Add your Redis health check logic here
// Example: await redis.ping()
healthData.services!.redis = "connected";
} catch (error) {
healthData.services!.redis = "error";
healthData.status = "error";
}
}
const statusCode = healthData.status === "ok" ? 200 : 503;
res.status(statusCode).json(healthData);
});
// Readiness probe (for Kubernetes)
router.get("/ready", (req: Request, res: Response): void => {
// Add any readiness checks here
// Example: check if database migrations are complete
res.status(200).json({
status: "ready",
timestamp: new Date().toISOString(),
});
});
// Liveness probe (for Kubernetes)
router.get("/live", (req: Request, res: Response): void => {
res.status(200).json({
status: "alive",
timestamp: new Date().toISOString(),
});
});
export default router;
api.ts
file:// File Name: api.ts
import { Router } from "express";
import usersRouter from "./users";
// Import other route modules here
// import productsRouter from './products';
// import ordersRouter from './orders';
const router = Router();
// API version info
router.get("/", (req, res) => {
res.json({
message: "API v1",
version: "1.0.0",
timestamp: new Date().toISOString(),
endpoints: {
users: "/api/users",
// Add other endpoints here
},
});
});
// Mount route modules
router.use("/users", usersRouter);
// router.use('/products', productsRouter);
// router.use('/orders', ordersRouter);
export default router;
src
named app.ts
and index.ts
:app.ts
which is our default app configuration:// File Name: app.ts
import express, { Application, Request, Response, NextFunction } from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
import morgan from "morgan";
import rateLimit from "express-rate-limit";
import "express-async-errors";
import { config } from "@/config/config";
import { logger } from "@/utils/logger";
import { globalErrorHandler } from "@/middleware/errorHandler";
// Import routes
import apiRouter from "@/routes/api";
import healthRouter from "@/routes/health";
import { requestLogger } from "./middleware/requestLogger";
import { validateApiKey } from "./middleware/auth";
import { notFoundHandler } from "./middleware/notFoundHandler";
class App {
public app: Application;
constructor() {
this.app = express();
this.configureMiddleware();
this.configureRoutes();
this.configureErrorHandling();
}
private configureMiddleware(): void {
// Security middleware
this.app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}),
);
// CORS configuration
this.app.use(
cors({
origin: config.corsOrigins,
credentials: true,
optionsSuccessStatus: 200,
}),
);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: config.rateLimitMax,
message: {
error: "Too many requests from this IP, please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
});
this.app.use("/api/", limiter);
// Compression and parsing
this.app.use(compression());
this.app.use(express.json({ limit: "10mb" }));
this.app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Logging
if (config.nodeEnv !== "test") {
this.app.use(
morgan("combined", {
stream: { write: (message: string) => logger.info(message.trim()) },
}),
);
}
this.app.use(requestLogger);
// Trust proxy for accurate IP addresses
this.app.set("trust proxy", 1);
}
private configureRoutes(): void {
// Health check route (no auth required)
this.app.use("/health", healthRouter);
// API routes with authentication
this.app.use("/api", validateApiKey, apiRouter);
// Root endpoint
this.app.get("/", (req: Request, res: Response) => {
res.status(200).json({
message: "Express TypeScript API Server",
version: "1.0.0",
timestamp: new Date().toISOString(),
environment: config.nodeEnv,
});
});
}
private configureErrorHandling(): void {
// 404 handler
this.app.use(notFoundHandler);
// Global error handler
this.app.use(globalErrorHandler);
}
public listen(port: number): void {
this.app.listen(port, () => {
logger.info(`Server running on port ${port} in ${config.nodeEnv} mode`);
});
}
}
export default App;
index.ts
:// File Name: index.ts
import "dotenv/config";
import App from "./app";
import { config } from "@/config/config";
import { logger } from "@/utils/logger";
// Handle uncaught exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception:", error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on(
"unhandledRejection",
(reason: unknown, promise: Promise<unknown>) => {
logger.error("Unhandled Rejection at:", promise, "reason:", reason);
process.exit(1);
},
);
// Graceful shutdown
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Shutting down gracefully...");
process.exit(0);
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Shutting down gracefully...");
process.exit(0);
});
// Start the application
const app = new App();
app.listen(config.port);
export default app;
health.test.ts
inside routes > __tests__
folder for testing our routes health:// File Name: health.test.ts
import request from "supertest";
import App from "../../app";
describe("Health Routes", () => {
let app: App;
beforeAll(() => {
app = new App();
});
describe("GET /health", () => {
it("should return basic health status", async () => {
const response = await request(app.app).get("/health").expect(200);
expect(response.body).toMatchObject({
status: "ok",
environment: "test",
version: expect.any(String),
uptime: expect.any(Number),
memory: {
used: expect.any(Number),
free: expect.any(Number),
total: expect.any(Number),
usage: expect.any(String),
},
system: {
platform: expect.any(String),
arch: expect.any(String),
nodeVersion: expect.any(String),
},
});
expect(response.body.timestamp).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
);
});
});
describe("GET /health/detailed", () => {
it("should return detailed health status", async () => {
const response = await request(app.app)
.get("/health/detailed")
.expect(200);
expect(response.body).toMatchObject({
status: "ok",
environment: "test",
services: expect.any(Object),
});
});
});
describe("GET /health/ready", () => {
it("should return readiness status", async () => {
const response = await request(app.app).get("/health/ready").expect(200);
expect(response.body).toMatchObject({
status: "ready",
timestamp: expect.any(String),
});
});
});
describe("GET /health/live", () => {
it("should return liveness status", async () => {
const response = await request(app.app).get("/health/live").expect(200);
expect(response.body).toMatchObject({
status: "alive",
timestamp: expect.any(String),
});
});
});
});
Dockerfile
# Multi-stage build for production optimization
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:18-alpine AS production
# Create app directory
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Create logs directory
RUN mkdir -p logs && chown -R nodejs:nodejs logs
# Change ownership of the app directory
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
# Start the application
CMD ["node", "dist/index.js"]
docker-compose.yml
:version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
target: production
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- API_KEY=${API_KEY}
- LOG_LEVEL=${LOG_LEVEL:-info}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100}
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=redis://redis:6379
depends_on:
- redis
volumes:
- ./logs:/app/logs
restart: unless-stopped
networks:
- app-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
networks:
- app-network
# Optional: PostgreSQL database
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB:-myapp}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- app-network
# Optional: Nginx reverse proxy
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
restart: unless-stopped
networks:
- app-network
volumes:
redis_data:
postgres_data:
networks:
app-network:
driver: bridge
π Getting Started:
npm install
cp .env.example .env
# Edit .env with your API_KEY
npm run dev
# Health check (no auth)
curl http://localhost:3000/health
# API endpoints (requires API key)
curl -H "x-api-key: your-api-key" http://localhost:3000/api/users
Running Tests
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
npm run dev
- Start development server with hot reloadnpm run build
- Build for productionnpm start
- Start production servernpm run lint
- Run ESLintnpm run lint:fix
- Fix ESLint errorsnpm run format
- Format code with Prettiernpm test
- Run testsnpm run test:watch
- Run tests in watch modenpm run test:coverage
- Run tests with coveragenpm run typecheck
- Check TypeScript typesThis setup incorporates all the latest 2025 best practices including strict TypeScript configuration, modern security middleware, comprehensive error handling, structured logging, and production-ready deployment options. The code is clean, well-documented, and follows industry standards for scalable Node.js applications.
2025 Standards
Production Ready
Developer Experience
Security First
Error Handling
Logging & Monitoring
Code Quality
Thank you for taking the time to read this till the end.
If you have any suggestions, questions, or feedback β feel free to reach out.
Happy coding! π