Express.js Best Practices: Build Production-Ready APIs With Node.js

Express.js Best Practices: Build Production-Ready APIs With Node.js
4 views

Express.js apps start simple and scale into unmaintainable spaghetti — unless you structure them right from the beginning. Middleware ordering, proper error handling, request validation, security headers, compression, and graceful shutdown are the foundations that turn a toy API into a production service. This guide focuses on Express.js best practices, with the production trade-offs senior engineers need to make sound decisions.

⚡ TL;DR: Middleware order matters: parse body → auth → rate limit → routes → error handler. Always validate with Zod/Joi before business logic. Use helmet for security headers. Compress responses. Structure routes by version and domain. Graceful shutdown on SIGTERM. Health check at /health.

Application structure and middleware order — Express.js best practices

const express = require('express');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');

const app = express();

// 1. Security headers (first!)
app.use(helmet());

// 2. Compression
app.use(compression());

// 3. Request ID for tracing
app.use((req,res,next)=>{ req.id=crypto.randomUUID(); next(); });

// 4. Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// 5. Rate limiting
app.use('/api/', rateLimit({ windowMs:60000, max:100 }));

// 6. Authentication
app.use('/api/', authenticateToken);

// 7. Routes
app.use('/api/v1/users', userRoutes);
app.use('/api/v1/posts', postRoutes);

// 8. 404 handler
app.use((req,res)=>res.status(404).json({error:'Not found'}));

// 9. Error handler LAST (4 params!)
app.use(errorHandler);

Router structure by domain

// routes/users.js
const router = express.Router();

// Middleware specific to this router:
router.use(requireAuth);

// CRUD routes:
router.get('/',         asyncHandler(UserController.list));
router.post('/',        validate(CreateUserDto), asyncHandler(UserController.create));
router.get('/:id',      asyncHandler(UserController.get));
router.patch('/:id',    validate(UpdateUserDto), asyncHandler(UserController.update));
router.delete('/:id',   asyncHandler(UserController.delete));

module.exports = router;

// app.js registers it:
app.use('/api/v1/users', userRoutes);
app.use('/api/v2/users', userRoutesV2); // Versioning

Request validation middleware

const { z } = require('zod');

const validate = schema => async (req,res,next) => {
  try {
    req.body = await schema.parseAsync(req.body);
    next();
  } catch(err) {
    if(err instanceof z.ZodError) {
      return res.status(422).json({
        error: 'Validation failed',
        fields: err.errors.map(e=>({path:e.path.join('.'),message:e.message}))
      });
    }
    next(err);
  }
};

const CreateUserDto = z.object({
  name: z.string().min(1).max(100).trim(),
  email: z.string().email().toLowerCase(),
  age: z.number().int().min(18).max(120)
});

router.post('/', validate(CreateUserDto), asyncHandler(UserController.create));

Health check and graceful shutdown

// Health check — returns 200 if healthy, 503 if degraded
app.get('/health', async (req,res) => {
  const checks = await Promise.allSettled([
    db.query('SELECT 1'),
    redis.ping(),
  ]);
  const healthy = checks.every(c=>c.status==='fulfilled');
  res.status(healthy?200:503).json({
    status: healthy?'healthy':'degraded',
    timestamp: new Date().toISOString()
  });
});

// Graceful shutdown:
const server = app.listen(process.env.PORT||3000);

process.on('SIGTERM',()=>{
  server.close(async()=>{
    await db.end();
    await redis.quit();
    process.exit(0);
  });
  setTimeout(()=>process.exit(1),30000);
});
  • ✅ Security headers first with helmet()
  • ✅ Compress responses (saves 60-80% bandwidth)
  • ✅ Validate ALL inputs before business logic
  • ✅ Router files per domain — not one giant routes file
  • ✅ Health check endpoint for load balancer probes
  • ❌ Middleware order matters — error handler must be last
  • ❌ Never skip validation — assume all input is adversarial

External reference: Express.js production best practices.

Recommended Reading

→ Designing Data-Intensive Applications — The bible of distributed systems and production engineering at scale.

→ The Pragmatic Programmer — Timeless engineering wisdom every senior developer needs.

Affiliate links. We earn a small commission at no extra cost to you.

Related on CheatCoders: Node.js API security best practices, OAuth 2.0 and JWT in production, fixing Node.js event-loop blocking, and Node.js production error handling.

Free Weekly Newsletter

🚀 Join 2,000+ Senior Developers

Get expert-level JavaScript, Python, AWS, system design and AI secrets every week. Zero fluff, pure signal.

✓ No spam✓ Unsubscribe anytime✓ Expert-level only

Related: Node.js Error Handling: From try/catch to Production Error Monitoring · Prompt Engineering for Developers: Patterns That Produce Production-Ready Code Every Time · REST API Design Best Practices: The Patterns That Make APIs a Joy to Use

Related: GraphQL vs REST: A Practical Comparison for Production APIs in 2025 · Day 97: Build vs Buy the Agent Runtime · Day 99: Capstone Build Week Checklist

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.