DEV Community

Lucas M Dev
Lucas M Dev

Posted on

Node.js Best Practices in 2026: What Senior Developers Actually Do

These aren't theoretical — they're patterns from production Node.js apps that handle millions of requests.

1. Structure your project by feature, not by type

# ❌ Bad — too abstract
src/
  controllers/
  models/
  routes/
  services/

# ✅ Good — feature-based
src/
  features/
    auth/
      auth.controller.js
      auth.service.js
      auth.routes.js
      auth.test.js
    users/
      users.controller.js
      users.service.js
Enter fullscreen mode Exit fullscreen mode

Why? When you work on "users", all relevant code is in one place. No more hunting across 5 folders.

2. Use async/await everywhere, handle errors properly

// ❌ Wrong - unhandled promise, crashes the process
app.get('/users/:id', async (req, res) => {
  const user = await UserService.findById(req.params.id);
  res.json(user);
});

// ✅ Better - catch errors, pass to error middleware
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await UserService.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  } catch (err) {
    next(err); // Let error middleware handle it
  }
});

// ✅ Even better - wrap all routes (Express 5 does this natively)
// Or use a helper:
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await UserService.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
}));
Enter fullscreen mode Exit fullscreen mode

3. Validate input at the edge

// Using Zod (best validation library in 2026)
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['user', 'admin', 'moderator']).default('user')
});

app.post('/users', async (req, res) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }

  const user = await UserService.create(result.data); // Type-safe!
  res.status(201).json(user);
});
Enter fullscreen mode Exit fullscreen mode

4. Centralized error handling

// errors.js - Custom error classes
export class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

export class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

export class ValidationError extends AppError {
  constructor(message) {
    super(message, 400, 'VALIDATION_ERROR');
  }
}

// Error middleware (must be LAST in Express)
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const response = {
    error: {
      message: err.message,
      code: err.code || 'INTERNAL_ERROR',
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    }
  };

  if (statusCode >= 500) {
    logger.error({ err, req: { method: req.method, url: req.url } });
  }

  res.status(statusCode).json(response);
});
Enter fullscreen mode Exit fullscreen mode

5. Use environment variables correctly

// config.js - Central config with validation
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
});

const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
  console.error('Invalid environment variables:', parsed.error.flatten());
  process.exit(1); // Fail fast — better than mysterious errors later
}

export const config = parsed.data;
// Usage: import { config } from './config.js'
Enter fullscreen mode Exit fullscreen mode

6. Logging that actually helps

// Use pino (fastest Node.js logger, outputs JSON)
import pino from 'pino';

export const logger = pino({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  transport: process.env.NODE_ENV !== 'production' ? {
    target: 'pino-pretty',
    options: { colorize: true }
  } : undefined
});

// Log with context, not just messages
logger.info({ userId: user.id, action: 'login', ip: req.ip }, 'User logged in');
logger.error({ err, orderId: order.id }, 'Payment failed');

// Add request logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration: Date.now() - start,
    });
  });
  next();
});
Enter fullscreen mode Exit fullscreen mode

7. Handle process signals gracefully

// Graceful shutdown — finish active requests before exiting
const server = app.listen(config.PORT);

async function shutdown(signal) {
  logger.info({ signal }, 'Shutting down...');

  server.close(async () => {
    try {
      await db.disconnect();
      await redis?.quit();
      logger.info('Graceful shutdown complete');
      process.exit(0);
    } catch (err) {
      logger.error({ err }, 'Error during shutdown');
      process.exit(1);
    }
  });

  // Force close after 30s
  setTimeout(() => {
    logger.error('Forced shutdown');
    process.exit(1);
  }, 30000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('unhandledRejection', (reason) => {
  logger.error({ reason }, 'Unhandled Promise rejection');
  shutdown('unhandledRejection');
});
Enter fullscreen mode Exit fullscreen mode

8. Rate limiting and security headers

import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

// Security headers (XSS, clickjacking, MIME sniffing)
app.use(helmet());

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' }
});

app.use('/api', limiter);

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 10,
  message: { error: 'Too many login attempts' }
});

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

The checklist

  • [ ] Feature-based project structure
  • [ ] Async error handling everywhere (try/catch or asyncHandler)
  • [ ] Input validation with Zod or Joi
  • [ ] Centralized error middleware
  • [ ] Environment variables validated on startup
  • [ ] Structured logging (pino or winston)
  • [ ] Graceful shutdown handling
  • [ ] Rate limiting + security headers (helmet)

What pattern do you use that's not on this list? Share it in the comments!

Top comments (0)