Node.js Best Practices 2025: Build Scalable and Maintainable Backend Applications
Master Node.js development with production-proven best practices for project structure, error handling, security, performance optimization, and deployment. Essential patterns for backend developers building modern applications.
Node.js powers some of the world's most demanding applications, from Netflix to PayPal. After building production backends for projects like InsureSignal and various client applications, I've distilled the practices that actually matter for building robust, scalable Node.js applications in 2025.
This guide covers everything from project architecture to deployment patterns.
Project Structure: The Foundation
A well-organized codebase pays dividends throughout a project's lifecycle. Here's the structure I use for production applications:
src/
├── config/ # Environment and app configuration
│ ├── index.ts
│ ├── database.ts
│ └── constants.ts
├── controllers/ # Route handlers (thin layer)
│ ├── auth.controller.ts
│ └── users.controller.ts
├── services/ # Business logic
│ ├── auth.service.ts
│ └── users.service.ts
├── repositories/ # Data access layer
│ ├── users.repository.ts
│ └── base.repository.ts
├── middleware/ # Express middleware
│ ├── auth.middleware.ts
│ ├── error.middleware.ts
│ └── validation.middleware.ts
├── models/ # Database models/schemas
│ └── user.model.ts
├── routes/ # Route definitions
│ ├── index.ts
│ └── v1/
│ ├── auth.routes.ts
│ └── users.routes.ts
├── utils/ # Helper functions
│ ├── logger.ts
│ ├── crypto.ts
│ └── validators.ts
├── types/ # TypeScript type definitions
│ ├── express.d.ts
│ └── api.types.ts
├── jobs/ # Background jobs
│ └── cleanup.job.ts
└── app.ts # Application entry point
Why This Structure Works
- Separation of concerns - Each layer has a single responsibility
- Testability - Services can be tested independently
- Scalability - Easy to add new features without touching existing code
- Team collaboration - Clear ownership of different areas
Configuration Management
Never hardcode configuration. Use environment variables with proper validation:
// config/index.ts
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('7d'),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
CORS_ORIGIN: z.string().default('*'),
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'),
RATE_LIMIT_MAX: z.string().transform(Number).default('100'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten());
process.exit(1);
}
export const config = parsed.data;
// Usage
import { config } from './config';
console.log(`Server running on port ${config.PORT}`);
Error Handling: Do It Right
Poor error handling causes more production incidents than bad algorithms. Build a robust error system:
// utils/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public code: string = 'INTERNAL_ERROR',
public isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string, public errors?: Record<string, string[]>) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class NotFoundError extends AppError {
constructor(resource: string = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class ConflictError extends AppError {
constructor(message: string = 'Resource already exists') {
super(message, 409, 'CONFLICT');
}
}
Global Error Handler
// middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { logger } from '../utils/logger';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
// Log error
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
});
// Operational errors - send to client
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
...(err instanceof ValidationError && { errors: err.errors }),
},
});
}
// Programming errors - don't leak details
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
// Async wrapper to catch promise rejections
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
Usage in Controllers
// controllers/users.controller.ts
import { asyncHandler } from '../middleware/error.middleware';
import { NotFoundError, ValidationError } from '../utils/errors';
import { userService } from '../services/users.service';
export const getUser = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json({ success: true, data: user });
});
export const createUser = asyncHandler(async (req, res) => {
const existingUser = await userService.findByEmail(req.body.email);
if (existingUser) {
throw new ConflictError('User with this email already exists');
}
const user = await userService.create(req.body);
res.status(201).json({ success: true, data: user });
});
Input Validation
Never trust user input. Validate everything at the API boundary:
// middleware/validation.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
import { ValidationError } from '../utils/errors';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
const errors = result.error.flatten();
throw new ValidationError('Validation failed', {
...errors.fieldErrors,
});
}
// Replace with parsed data (includes transformations)
req.body = result.data.body;
req.query = result.data.query;
req.params = result.data.params;
next();
};
}
// schemas/user.schema.ts
export const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin']).default('user'),
}),
});
export const updateUserSchema = z.object({
params: z.object({
id: z.string().uuid('Invalid user ID'),
}),
body: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
}),
});
// Usage in routes
router.post('/users', validate(createUserSchema), createUser);
router.patch('/users/:id', validate(updateUserSchema), updateUser);
Security Best Practices
Security isn't optional. Implement these measures from day one:
Helmet and Security Headers
// app.ts
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
const app = express();
// Security headers
app.use(helmet());
// CORS configuration
app.use(cors({
origin: config.CORS_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Rate limiting
const limiter = rateLimit({
windowMs: config.RATE_LIMIT_WINDOW_MS,
max: config.RATE_LIMIT_MAX,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later',
},
},
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', limiter);
Password Hashing
// utils/crypto.ts
import bcrypt from 'bcrypt';
import crypto from 'crypto';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function generateToken(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
export function generateOTP(): string {
return crypto.randomInt(100000, 999999).toString();
}
JWT Authentication
// middleware/auth.middleware.ts
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { UnauthorizedError } from '../utils/errors';
import { userService } from '../services/users.service';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export function generateToken(payload: TokenPayload): string {
return jwt.sign(payload, config.JWT_SECRET, {
expiresIn: config.JWT_EXPIRES_IN,
});
}
export function verifyToken(token: string): TokenPayload {
try {
return jwt.verify(token, config.JWT_SECRET) as TokenPayload;
} catch (error) {
throw new UnauthorizedError('Invalid or expired token');
}
}
export async function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('No token provided');
}
const token = authHeader.split(' ')[1];
const payload = verifyToken(token);
const user = await userService.findById(payload.userId);
if (!user) {
throw new UnauthorizedError('User not found');
}
req.user = user;
next();
}
export function authorize(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
throw new UnauthorizedError();
}
if (!roles.includes(req.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
next();
};
}
Database Best Practices
Whether using Prisma or raw queries, follow these patterns:
Connection Pooling
// config/database.ts
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: config.DATABASE_URL,
max: 20, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});
Transaction Handling
// repositories/base.repository.ts
import { pool } from '../config/database';
export async function withTransaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Usage
const result = await withTransaction(async (client) => {
const user = await client.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
[email, name]
);
await client.query(
'INSERT INTO user_settings (user_id) VALUES ($1)',
[user.rows[0].id]
);
return user.rows[0];
});
Logging That Actually Helps
Good logs save hours of debugging time:
// utils/logger.ts
import winston from 'winston';
import { config } from '../config';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
);
export const logger = winston.createLogger({
level: config.LOG_LEVEL,
format: logFormat,
defaultMeta: { service: 'api' },
transports: [
new winston.transports.Console({
format: config.NODE_ENV === 'development'
? winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
: logFormat,
}),
// Production: Add file transport or external service
...(config.NODE_ENV === 'production'
? [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
]
: []),
],
});
// Request logging middleware
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info({
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
userAgent: req.get('user-agent'),
});
});
next();
}
Performance Optimization
Caching with Redis
// utils/cache.ts
import Redis from 'ioredis';
import { config } from '../config';
const redis = config.REDIS_URL ? new Redis(config.REDIS_URL) : null;
export async function cacheGet<T>(key: string): Promise<T | null> {
if (!redis) return null;
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : null;
}
export async function cacheSet(
key: string,
value: any,
ttlSeconds: number = 3600
): Promise<void> {
if (!redis) return;
await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
export async function cacheDelete(key: string): Promise<void> {
if (!redis) return;
await redis.del(key);
}
// Cache middleware
export function cacheMiddleware(ttlSeconds: number = 300) {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') {
return next();
}
const cacheKey = `cache:${req.originalUrl}`;
const cached = await cacheGet(cacheKey);
if (cached) {
return res.json(cached);
}
// Store original json method
const originalJson = res.json.bind(res);
// Override to cache response
res.json = (data: any) => {
cacheSet(cacheKey, data, ttlSeconds);
return originalJson(data);
};
next();
};
}
Response Compression
import compression from 'compression';
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6, // Balance between speed and compression
}));
Testing Strategies
Write tests that catch real bugs:
// __tests__/users.service.test.ts
import { userService } from '../services/users.service';
import { pool } from '../config/database';
describe('UserService', () => {
beforeAll(async () => {
// Setup test database
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
// Clean up between tests
await pool.query('DELETE FROM users');
});
describe('create', () => {
it('should create a user with hashed password', async () => {
const user = await userService.create({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
});
expect(user.email).toBe('test@example.com');
expect(user.password).not.toBe('password123'); // Should be hashed
});
it('should throw ConflictError for duplicate email', async () => {
await userService.create({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
});
await expect(
userService.create({
email: 'test@example.com',
password: 'password456',
name: 'Another User',
})
).rejects.toThrow(ConflictError);
});
});
});
Graceful Shutdown
Handle process termination properly:
// app.ts
import { server } from './server';
import { pool } from './config/database';
import { logger } from './utils/logger';
async function gracefulShutdown(signal: string) {
logger.info(`Received ${signal}. Starting graceful shutdown...`);
// Stop accepting new connections
server.close(async (err) => {
if (err) {
logger.error('Error during server close:', err);
process.exit(1);
}
// Close database connections
await pool.end();
logger.info('Database pool closed');
// Close Redis connections
// await redis.quit();
logger.info('Graceful shutdown completed');
process.exit(0);
});
// Force shutdown after 30 seconds
setTimeout(() => {
logger.error('Forced shutdown due to timeout');
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Deployment Checklist
Before deploying to production:
- [ ] Environment variables validated
- [ ] Database migrations applied
- [ ] Rate limiting configured
- [ ] CORS properly set
- [ ] Error handling catches all cases
- [ ] Logging configured for production
- [ ] Health check endpoint exists
- [ ] Graceful shutdown implemented
- [ ] Security headers enabled
- [ ] SSL/TLS configured
- [ ] Monitoring and alerting set up
For detailed deployment guidance, check out my Vercel deployment guide which covers CI/CD pipelines and production configurations.
Conclusion
Building production-ready Node.js applications requires attention to detail across many areas. The practices in this guide have served me well across multiple projects and will help you avoid common pitfalls.
Remember: the best code is code that's easy to maintain, test, and debug. Prioritize clarity over cleverness, and always handle errors gracefully.
For more backend development resources, explore my posts on Prisma ORM and building fintech applications.
Keep building, keep learning.