Building Scalable APIs with Node.js and Express 2025
Learn how to build high-performance, scalable REST APIs using Node.js, Express, and modern development practices.

Emre Tekir
Frontend Developer & SDK
Building Scalable APIs with Node.js and Express 2025
Node.js continues to be a dominant force in backend development. This comprehensive guide will show you how to build production-ready, scalable APIs using Node.js and Express with modern best practices.
Why Node.js for API Development?
Key Advantages
- Non-blocking I/O: Perfect for I/O-intensive applications
- JavaScript Everywhere: Same language for frontend and backend
- Rich Ecosystem: Massive npm package library
- Microservices Ready: Lightweight and fast startup times
- JSON Native: Perfect for REST APIs and JSON handling
Project Setup and Structure
Modern Project Initialization
mkdir scalable-api
cd scalable-api
npm init -y
# Install core dependencies
npm install express cors helmet morgan compression
npm install dotenv bcryptjs jsonwebtoken
npm install express-rate-limit express-validator
# Install development dependencies
npm install -D nodemon jest supertest eslint prettier
Project Structure
src/
├── controllers/ # Request handlers
├── middleware/ # Custom middleware
├── models/ # Data models
├── routes/ # Route definitions
├── services/ # Business logic
├── utils/ # Utility functions
├── config/ # Configuration files
├── tests/ # Test files
└── app.js # Express app setup
Express Application Setup
Basic Express Configuration
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP',
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Compression and parsing
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan('combined'));
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
// Error handling middleware
app.use(require('./middleware/errorHandler'));
module.exports = app;
Environment Configuration
// src/config/database.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error('Database connection error:', error);
process.exit(1);
}
};
module.exports = connectDB;
Request Validation and Sanitization
Input Validation Middleware
// src/middleware/validation.js
const { body, validationResult } = require('express-validator');
const validateUser = [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Valid email is required'),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
.withMessage('Password must contain at least 8 characters with uppercase, lowercase, number, and special character'),
body('username')
.isLength({ min: 3, max: 30 })
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Username must be 3-30 characters, alphanumeric and underscores only'),
];
const validatePost = [
body('title')
.trim()
.isLength({ min: 1, max: 200 })
.withMessage('Title is required and must be less than 200 characters'),
body('content')
.trim()
.isLength({ min: 10 })
.withMessage('Content must be at least 10 characters long'),
body('tags')
.optional()
.isArray()
.withMessage('Tags must be an array'),
];
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
}
next();
};
module.exports = {
validateUser,
validatePost,
handleValidationErrors,
};
Authentication and Authorization
JWT Authentication Middleware
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
message: 'Access token required',
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.userId).select('-password');
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid token',
});
}
req.user = user;
next();
} catch (error) {
return res.status(403).json({
success: false,
message: 'Invalid or expired token',
});
}
};
const authorizeRoles = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: 'Access denied. Insufficient permissions.',
});
}
next();
};
};
module.exports = {
authenticateToken,
authorizeRoles,
};
User Authentication Controller
// src/controllers/authController.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const generateTokens = (userId) => {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
};
const register = async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if user exists
const existingUser = await User.findOne({
$or: [{ email }, { username }]
});
if (existingUser) {
return res.status(400).json({
success: false,
message: 'User with this email or username already exists',
});
}
// Hash password
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Create user
const user = new User({
username,
email,
password: hashedPassword,
});
await user.save();
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user._id);
// Set refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
user: {
id: user._id,
username: user.username,
email: user.email,
role: user.role,
},
accessToken,
},
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
const login = async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({
success: false,
message: 'Invalid credentials',
});
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(400).json({
success: false,
message: 'Invalid credentials',
});
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user._id);
// Set refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({
success: true,
message: 'Login successful',
data: {
user: {
id: user._id,
username: user.username,
email: user.email,
role: user.role,
},
accessToken,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
module.exports = {
register,
login,
};
Database Models and Relationships
User Model
// src/models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 30,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: true,
minlength: 8,
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user',
},
profile: {
firstName: String,
lastName: String,
avatar: String,
bio: String,
},
isActive: {
type: Boolean,
default: true,
},
lastLogin: Date,
}, {
timestamps: true,
});
// Indexes for performance
userSchema.index({ email: 1 });
userSchema.index({ username: 1 });
userSchema.index({ createdAt: -1 });
// Virtual for full name
userSchema.virtual('fullName').get(function() {
return `${this.profile.firstName} ${this.profile.lastName}`.trim();
});
module.exports = mongoose.model('User', userSchema);
Post Model with Relations
// src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 200,
},
content: {
type: String,
required: true,
minlength: 10,
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
tags: [{
type: String,
trim: true,
lowercase: true,
}],
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft',
},
publishedAt: Date,
views: {
type: Number,
default: 0,
},
likes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
}],
comments: [{
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
content: {
type: String,
required: true,
maxlength: 1000,
},
createdAt: {
type: Date,
default: Date.now,
},
}],
}, {
timestamps: true,
});
// Indexes
postSchema.index({ author: 1, status: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ publishedAt: -1 });
postSchema.index({ title: 'text', content: 'text' });
// Virtual for like count
postSchema.virtual('likeCount').get(function() {
return this.likes.length;
});
module.exports = mongoose.model('Post', postSchema);
RESTful Route Design
Posts Router
// src/routes/posts.js
const express = require('express');
const router = express.Router();
const { authenticateToken, authorizeRoles } = require('../middleware/auth');
const { validatePost, handleValidationErrors } = require('../middleware/validation');
const postController = require('../controllers/postController');
// Public routes
router.get('/', postController.getAllPosts);
router.get('/:id', postController.getPostById);
// Protected routes
router.use(authenticateToken);
router.post('/',
validatePost,
handleValidationErrors,
postController.createPost
);
router.put('/:id',
validatePost,
handleValidationErrors,
postController.updatePost
);
router.delete('/:id', postController.deletePost);
// Interaction routes
router.post('/:id/like', postController.likePost);
router.post('/:id/comments', postController.addComment);
// Admin only routes
router.patch('/:id/status',
authorizeRoles('admin', 'moderator'),
postController.updatePostStatus
);
module.exports = router;
Posts Controller
// src/controllers/postController.js
const Post = require('../models/Post');
const { AppError } = require('../utils/AppError');
const getAllPosts = async (req, res, next) => {
try {
const {
page = 1,
limit = 10,
status = 'published',
author,
tags,
search,
sortBy = 'publishedAt',
sortOrder = 'desc'
} = req.query;
// Build query
const query = { status };
if (author) query.author = author;
if (tags) query.tags = { $in: tags.split(',') };
if (search) {
query.$text = { $search: search };
}
// Execute query with pagination
const posts = await Post.find(query)
.populate('author', 'username profile.firstName profile.lastName')
.sort({ [sortBy]: sortOrder === 'desc' ? -1 : 1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.lean();
const total = await Post.countDocuments(query);
res.json({
success: true,
data: {
posts,
pagination: {
current: parseInt(page),
pages: Math.ceil(total / limit),
total,
},
},
});
} catch (error) {
next(error);
}
};
const createPost = async (req, res, next) => {
try {
const { title, content, tags, status } = req.body;
const post = new Post({
title,
content,
tags,
status,
author: req.user._id,
publishedAt: status === 'published' ? new Date() : undefined,
});
await post.save();
await post.populate('author', 'username profile.firstName profile.lastName');
res.status(201).json({
success: true,
message: 'Post created successfully',
data: { post },
});
} catch (error) {
next(error);
}
};
const updatePost = async (req, res, next) => {
try {
const { id } = req.params;
const { title, content, tags, status } = req.body;
const post = await Post.findOne({ _id: id, author: req.user._id });
if (!post) {
throw new AppError('Post not found or unauthorized', 404);
}
// Update fields
post.title = title;
post.content = content;
post.tags = tags;
// Handle status change
if (status !== post.status) {
post.status = status;
if (status === 'published' && !post.publishedAt) {
post.publishedAt = new Date();
}
}
await post.save();
await post.populate('author', 'username profile.firstName profile.lastName');
res.json({
success: true,
message: 'Post updated successfully',
data: { post },
});
} catch (error) {
next(error);
}
};
module.exports = {
getAllPosts,
createPost,
updatePost,
};
Error Handling and Logging
Global Error Handler
// src/middleware/errorHandler.js
const { AppError } = require('../utils/AppError');
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
console.error(err);
// Mongoose bad ObjectId
if (err.name === 'CastError') {
const message = 'Resource not found';
error = new AppError(message, 404);
}
// Mongoose duplicate key
if (err.code === 11000) {
const message = 'Duplicate field value entered';
error = new AppError(message, 400);
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map(val => val.message);
error = new AppError(message, 400);
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
const message = 'Invalid token';
error = new AppError(message, 401);
}
if (err.name === 'TokenExpiredError') {
const message = 'Token expired';
error = new AppError(message, 401);
}
res.status(error.statusCode || 500).json({
success: false,
message: error.message || 'Server Error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};
module.exports = errorHandler;
Custom Error Class
// src/utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = { AppError };
Testing Strategy
API Testing with Jest and Supertest
// src/tests/auth.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../app');
const User = require('../models/User');
describe('Auth Endpoints', () => {
beforeEach(async () => {
await User.deleteMany({});
});
afterAll(async () => {
await mongoose.connection.close();
});
describe('POST /api/auth/register', () => {
it('should register a new user', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'Test123!@#',
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.user.email).toBe(userData.email);
expect(response.body.data.accessToken).toBeDefined();
});
it('should not register user with invalid email', async () => {
const userData = {
username: 'testuser',
email: 'invalid-email',
password: 'Test123!@#',
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.errors).toBeDefined();
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
const user = new User({
username: 'testuser',
email: 'test@example.com',
password: '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewWKPxJf6rLyXsXu', // Test123!@#
});
await user.save();
});
it('should login with valid credentials', async () => {
const loginData = {
email: 'test@example.com',
password: 'Test123!@#',
};
const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.accessToken).toBeDefined();
});
});
});
Performance Optimization
Database Query Optimization
// src/services/postService.js
const Post = require('../models/Post');
class PostService {
static async getPostsWithAggregation(filters = {}) {
const pipeline = [
{ $match: filters },
{
$lookup: {
from: 'users',
localField: 'author',
foreignField: '_id',
as: 'author',
pipeline: [
{ $project: { username: 1, 'profile.firstName': 1, 'profile.lastName': 1 } }
]
}
},
{ $unwind: '$author' },
{
$addFields: {
likeCount: { $size: '$likes' },
commentCount: { $size: '$comments' }
}
},
{ $sort: { publishedAt: -1 } }
];
return await Post.aggregate(pipeline);
}
static async getPopularPosts(limit = 10) {
return await Post.find({ status: 'published' })
.sort({ views: -1, likes: -1 })
.limit(limit)
.populate('author', 'username profile.firstName profile.lastName')
.lean();
}
}
module.exports = PostService;
Caching with Redis
// src/middleware/cache.js
const redis = require('redis');
const client = redis.createClient(process.env.REDIS_URL);
const cache = (duration = 300) => { // 5 minutes default
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
try {
const cached = await client.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
// Store original json method
const originalJson = res.json;
// Override json method to cache response
res.json = function(data) {
if (res.statusCode === 200) {
client.setex(key, duration, JSON.stringify(data));
}
originalJson.call(this, data);
};
next();
} catch (error) {
console.error('Cache error:', error);
next();
}
};
};
module.exports = cache;
Deployment and Production
Production Configuration
// src/config/production.js
module.exports = {
// Database
mongodb: {
uri: process.env.MONGODB_URI,
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
},
},
// JWT
jwt: {
secret: process.env.JWT_SECRET,
refreshSecret: process.env.JWT_REFRESH_SECRET,
expiresIn: '15m',
refreshExpiresIn: '7d',
},
// CORS
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
},
// Rate limiting
rateLimit: {
windowMs: 15 * 60 * 1000,
max: 100,
},
};
Docker Configuration
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Health Check Endpoint
// src/routes/health.js
const express = require('express');
const mongoose = require('mongoose');
const router = express.Router();
router.get('/', async (req, res) => {
const health = {
uptime: process.uptime(),
message: 'OK',
timestamp: Date.now(),
checks: {
database: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected',
memory: process.memoryUsage(),
},
};
res.json(health);
});
module.exports = router;
Conclusion
Building scalable APIs with Node.js and Express requires attention to security, performance, and maintainability. By following these patterns and best practices, you can create robust backend services that handle growth effectively.
Key takeaways for scalable API development:
- Implement proper validation and error handling
- Use middleware for cross-cutting concerns
- Design RESTful endpoints with consistent patterns
- Optimize database queries and implement caching
- Write comprehensive tests
- Monitor and log application performance
Ready to build your scalable API? Contact AestheteSoft for expert Node.js development services.
This article is part of the AestheteSoft blog series. Follow our blog for more insights on backend development and API design.