Advance Backend Setup
Monday, August 4, 2025
•By Kanad Shee
Node.js with Express, TypeScript, Winston Logger, and more

Backend (Node + TS)
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:
- ⚙️ Node.js for the runtime
- 🧭 Express for routing and middleware
- 🧑💻 TypeScript for static typing and IDE support
- 📑 Winston for structured, configurable logging
Key Features
Tools and Technologies
| Tool | Purpose |
|---|---|
| Node.js | Runtime environment |
| Express | Web framework / routing |
| TypeScript | Static typing / developer DX |
| Winston | Structured logging |
| Jest + Supertest | Testing & API tests |
| ESLint + Prettier | Linting & formatting |
Tech Stack
- Node.js 18+ with TypeScript 5.4+
- Express.js 4.19+ with comprehensive middleware
- ESLint + Prettier for code quality
- Jest + Supertest for testing
- Winston for structured logging
Security
- Helmet for security headers
- CORS configuration
- Rate limiting (express-rate-limit)
- Input validation (express-validator)
- API key authentication
- Request ID tracking
- Graceful shutdown handling
Dev Experience
- Hot reload with tsx
- Pre-commit hooks with Husky
- Comprehensive error handling
- Health check endpoints
Structure
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:
- Configuration management with environment validation
- Custom middleware for auth, logging, and error handling
- RESTful API routes with full CRUD operations
- Comprehensive health checks for monitoring
- Docker support for containerized deployment
Lets get into deep in all files:
- First create a package.json file
npm init -y
- Now replace this file with this:
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"]
}
}
- Now move to terminal and run,
npm install
- Now, we have to initialize typescript for our project:
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"]
}
Linting
- First create a
eslint.jsonfile 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"]
}
- Now, setup prettier by creating one more file
.prettierrc:
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
- Now create a jest config also for testing named
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'],
};
.gitignore
# 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/
- Now in project ROOT create a src directory and inside that create some more directories and two files:
- config
- middleware
- routes
- tests
- utils
- app.ts
- index.ts
Env Variables
# 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
Project Setup
- Inside,
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.tsfile:
// 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);
}
}
- Next create a
logger.tsfile:
// 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());
},
};
Middleware
-
auth.ts
-
errorHandler.ts
-
health.ts
-
notFoundHandler.ts
-
requestLogger.ts
-
First create the
auth.tsfile:
// 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();
};
};
- Lets create the
errorHandler.tsfile:
// 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);
};
- We will also need a middleware to check server health. So create
health.tsfile:
// 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;
- Now, create the
notFoundHandler.tsfile:
// 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));
};
- And the last file
requestLogger.tsto 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
- So, create a file
setup.tsinsidetestsdirectory insidesrc:
// 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(),
};
- So,the test setup is complete.
Routes
├── routes/ # Route definitions
│ ├── api.ts # Main API routes
│ ├── health.ts # Health check routes
│ └── users.ts # User routes
- First create the
users.tsfile:
// 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;
- Now, create the
health.tsfile:
// 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;
- Next create
api.tsfile:
// 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;
App & Entry
- Lets first create the
app.tswhich 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;
- Now, create the entry point for our server,
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;
- Now create a new file
health.test.tsinsideroutes > __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),
});
});
});
});
Dockerizing the App:
- So create a docker file named
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"]
- Now create a docker compose file named
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
Get Started:
🛠 Getting Started:
- Install dependencies:
npm install
- Setup Environment:
cp .env.example .env
# Edit .env with your API_KEY
- Start Devlopment:
npm run dev
- Test the API:
# 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
Testing
Running Tests
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
Scripts
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 types
Production
- Docker support with multi-stage builds
- Docker Compose for full stack deployment
- PM2 process management
- Nginx reverse proxy configuration
- Health checks for Kubernetes/monitoring
This 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.
Best Practices
2025 Standards
- ES2022 target for modern JavaScript features
- Strict TypeScript configuration
- Path aliases for clean imports
- Environment validation with type safety
- Graceful shutdown handling
Production Ready
- Error boundaries for unhandled exceptions
- Request ID tracking for distributed tracing
- Memory and performance monitoring
- Structured configuration management
- Comprehensive health checks
Developer Experience
- Hot reload in development
- Automatic formatting and linting
- Pre-commit hooks for code quality
- Type-safe environment configuration
- Detailed error messages
Architecture Decisions
Security First
- Helmet for security headers
- CORS configuration for cross-origin requests
- Rate limiting to prevent abuse
- Input validation on all endpoints
- API key authentication for protected routes
Error Handling
- Custom error classes for different HTTP status codes
- Global error handler for consistent error responses
- Async error handling with express-async-errors
- Request ID tracking for debugging
Logging & Monitoring
- Structured logging with Winston
- Request/response logging with unique request IDs
- Health check endpoints for monitoring
- Performance metrics (response time, memory usage)
Code Quality
- TypeScript for type safety
- ESLint with TypeScript rules
- Prettier for code formatting
- Husky for git hooks
- Jest for testing
🙏 Thank You
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! 🚀