Building Scalable Node.js APIs with Express and TypeScript: A Production-Ready Setup
Introduction
Building scalable APIs is crucial for modern web applications. While JavaScript has been the go-to language for Node.js development, TypeScript has gained massive adoption due to its type safety and developer experience improvements. In this guide, we'll create a production-ready Express API with TypeScript that follows best practices for scalability and maintainability.
Setting Up the Project Structure
A well-organized project structure is essential for long-term maintainability. Here's the structure we'll implement:
src/
├── controllers/
├── middleware/
├── models/
├── routes/
├── services/
├── types/
├── utils/
└── app.tsFirst, initialize your project and install dependencies:
npm init -y
npm install express cors helmet morgan dotenv
npm install -D typescript @types/node @types/express @types/cors ts-node nodemonTypeScript Configuration
Create a tsconfig.json file with strict type checking enabled:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Creating Type Definitions
Define your API types in src/types/index.ts:
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface ApiResponse {
success: boolean;
data?: T;
message?: string;
error?: string;
} Implementing Middleware
Create reusable middleware in src/middleware/. Here's an error handling middleware:
import { Request, Response, NextFunction } from 'express';
import { ApiResponse } from '../types';
export class AppError extends Error {
statusCode: number;
isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export const errorHandler = (
err: AppError | Error,
req: Request,
res: Response>,
next: NextFunction
) => {
const statusCode = err instanceof AppError ? err.statusCode : 500;
const message = err.message || 'Internal Server Error';
console.error('Error:', err);
res.status(statusCode).json({
success: false,
error: message
});
}; Add a validation middleware using a simple validation function:
import { Request, Response, NextFunction } from 'express';
import { AppError } from './errorHandler';
export const validateCreateUser = (
req: Request,
res: Response,
next: NextFunction
) => {
const { email, name, password } = req.body;
if (!email || !name || !password) {
throw new AppError('Email, name, and password are required', 400);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new AppError('Invalid email format', 400);
}
if (password.length < 6) {
throw new AppError('Password must be at least 6 characters', 400);
}
next();
};Building Controllers with Proper Error Handling
Create controllers in src/controllers/userController.ts:
import { Request, Response, NextFunction } from 'express';
import { ApiResponse, User, CreateUserRequest } from '../types';
import { UserService } from '../services/userService';
export class UserController {
private userService: UserService;
constructor() {
this.userService = new UserService();
}
createUser = async (
req: Request<{}, ApiResponse, CreateUserRequest>,
res: Response>,
next: NextFunction
) => {
try {
const user = await this.userService.createUser(req.body);
res.status(201).json({
success: true,
data: user,
message: 'User created successfully'
});
} catch (error) {
next(error);
}
};
getUsers = async (
req: Request,
res: Response>,
next: NextFunction
) => {
try {
const users = await this.userService.getAllUsers();
res.json({
success: true,
data: users
});
} catch (error) {
next(error);
}
};
} Service Layer for Business Logic
Implement business logic in src/services/userService.ts:
import { User, CreateUserRequest } from '../types';
import { AppError } from '../middleware/errorHandler';
export class UserService {
private users: User[] = [];
async createUser(userData: CreateUserRequest): Promise {
// Check if user already exists
const existingUser = this.users.find(u => u.email === userData.email);
if (existingUser) {
throw new AppError('User with this email already exists', 409);
}
const user: User = {
id: Date.now().toString(),
email: userData.email,
name: userData.name,
createdAt: new Date(),
updatedAt: new Date()
};
this.users.push(user);
return user;
}
async getAllUsers(): Promise {
return this.users;
}
async getUserById(id: string): Promise {
const user = this.users.find(u => u.id === id);
if (!user) {
throw new AppError('User not found', 404);
}
return user;
}
} Setting Up Routes and Main Application
Create routes in src/routes/userRoutes.ts:
import { Router } from 'express';
import { UserController } from '../controllers/userController';
import { validateCreateUser } from '../middleware/validation';
const router = Router();
const userController = new UserController();
router.post('/users', validateCreateUser, userController.createUser);
router.get('/users', userController.getUsers);
export default router;Finally, set up the main application in src/app.ts:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import userRoutes from './routes/userRoutes';
import { errorHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/v1', userRoutes);
// Error handling
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
export default app;Best Practices Implemented
This setup incorporates several production-ready practices:
- Type Safety: Full TypeScript coverage with strict checking
- Error Handling: Centralized error management with custom error classes
- Validation: Input validation middleware
- Security: Helmet for security headers, CORS configuration
- Separation of Concerns: Clear distinction between controllers, services, and routes
- Logging: Morgan for request logging
Conclusion
This Express and TypeScript setup provides a solid foundation for building scalable APIs. The modular structure, comprehensive error handling, and type safety will help maintain code quality as your application grows. Consider adding database integration, authentication, testing, and API documentation as next steps for a complete production setup.
Related Posts
Mastering Laravel Queues: A Complete Guide to Background Job Processing
Learn how to implement and optimize Laravel queues for better application performance and user experience.
Building Scalable GraphQL APIs with DataLoader in Node.js
Learn how to eliminate the N+1 query problem in GraphQL using Facebook's DataLoader pattern for efficient data fetching.
Building a Real-Time Chat Application with NestJS and WebSockets
Learn to build a production-ready real-time chat application using NestJS WebSocket gateway and Socket.IO integration.