Building Production-Ready REST APIs with Node.js
I remember deploying my first Node.js API to a VPS I rented for ₹500/month. It crashed within hours because I hadn't considered what "production-ready" actually meant. Here's everything I learned the hard way.
Project Structure That Scales
Forget the tutorials that put everything in app.js. Here's how real companies organize their Node projects:
src/
├── config/
│ ├── database.js
│ └── environment.js
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── utils/
└── app.js
Setting Up Express Properly
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
// Rate limiting - essential for production
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per window
});
app.use('/api/', limiter);
app.use(express.json({ limit: '10kb' }));
Authentication Done Right
JWT is industry standard, but implementation matters:
const jwt = require('jsonwebtoken');
const generateTokens = (userId) => {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
};
Store refresh tokens in Redis or your database, not just in memory.
Input Validation
Never trust user input. I use Joi for validation:
const Joi = require('joi');
const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).pattern(/^(?=.*[A-Z])(?=.*\d)/).required(),
phone: Joi.string().pattern(/^[6-9]\d{9}$/) // Indian mobile format
});
const validateUser = (req, res, next) => {
const { error } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
next();
};
Error Handling
Create a centralized error handler:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
status: 'error',
message: err.isOperational ? err.message : 'Something went wrong'
});
if (!err.isOperational) {
console.error('CRITICAL ERROR:', err);
}
};
Database Connection with Retry Logic
const mongoose = require('mongoose');
const connectDB = async (retries = 5) => {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('Database connected');
} catch (err) {
if (retries > 0) {
console.log(`Retrying connection... (${retries} attempts left)`);
await new Promise(r => setTimeout(r, 5000));
return connectDB(retries - 1);
}
throw err;
}
};
Deployment Checklist
Before going live:
- Environment variables properly set
- CORS configured for production domains
- Rate limiting enabled
- Logging configured (use Winston or Pino)
- Health check endpoint at
/health - Process manager (PM2) configured
Your API should handle failures gracefully. Users don't care why something broke—they just want it to work.