Learn how to build a professional-grade REST API using Express.js with modern practices and tools. ๐
Project Setup ๐ฏ
First, let's set up our project with TypeScript and essential dependencies.
# Initialize project
mkdir express-api
cd express-api
npm init -y
# Install dependencies
npm install express mongoose dotenv cors helmet express-rate-limit
npm install jsonwebtoken bcryptjs winston express-validator
# Install dev dependencies
npm install -D typescript @types/express @types/node ts-node-dev
npm install -D @types/cors @types/jsonwebtoken @types/bcryptjs
Project Structure ๐
express-api/
โโโ src/
โ โโโ config/
โ โ โโโ database.ts
โ โ โโโ logger.ts
โ โโโ controllers/
โ โ โโโ user.controller.ts
โ โโโ middleware/
โ โ โโโ auth.middleware.ts
โ โ โโโ error.middleware.ts
โ โ โโโ validation.middleware.ts
โ โโโ models/
โ โ โโโ user.model.ts
โ โโโ routes/
โ โ โโโ user.routes.ts
โ โโโ services/
โ โ โโโ user.service.ts
โ โโโ utils/
โ โ โโโ helpers.ts
โ โโโ app.ts
โโโ .env
โโโ package.json
โโโ tsconfig.json
Configuration ๐ง
TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Environment Variables
# .env
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/api
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=7d
Database Setup ๐๏ธ
// src/config/database.ts
import mongoose from 'mongoose';
import logger from './logger';
export async function connectDB(): Promise<void> {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI!);
logger.info(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
logger.error('Error connecting to MongoDB:', error);
process.exit(1);
}
}
Logging Configuration ๐
// src/config/logger.ts
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
export default logger;
Models ๐
// src/models/user.model.ts
import { Schema, model, Document } from 'mongoose';
import bcrypt from 'bcryptjs';
export interface IUser extends Document {
email: string;
password: string;
name: string;
role: 'user' | 'admin';
comparePassword(password: string): Promise<boolean>;
}
const userSchema = new Schema<IUser>({
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
required: true,
select: false
},
name: {
type: String,
required: true
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
}
}, {
timestamps: true
});
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error: any) {
next(error);
}
});
userSchema.methods.comparePassword = async function(
password: string
): Promise<boolean> {
return bcrypt.compare(password, this.password);
};
export const User = model<IUser>('User', userSchema);
Middleware ๐
Authentication Middleware
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { User } from '../models/user.model';
interface JwtPayload {
id: string;
}
declare global {
namespace Express {
interface Request {
user?: any;
}
}
}
export const protect = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
status: 'error',
message: 'Not authorized to access this route'
});
}
const decoded = jwt.verify(
token,
process.env.JWT_SECRET!
) as JwtPayload;
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({
status: 'error',
message: 'User no longer exists'
});
}
req.user = user;
next();
} catch (error) {
res.status(401).json({
status: 'error',
message: 'Not authorized to access this route'
});
}
};
export const authorize = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
status: 'error',
message: 'Not authorized to perform this action'
});
}
next();
};
};
Error Handling Middleware
// src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import logger from '../config/logger';
export class AppError extends Error {
statusCode: number;
status: string;
isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export const errorHandler = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
logger.error({
message: err.message,
stack: err.stack
});
if (process.env.NODE_ENV === 'development') {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
} else {
res.status(err.statusCode).json({
status: err.status,
message: err.isOperational ? err.message : 'Something went wrong'
});
}
};
Controllers ๐ฎ
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { User } from '../models/user.model';
import { AppError } from '../middleware/error.middleware';
import jwt from 'jsonwebtoken';
const signToken = (id: string) => {
return jwt.sign(
{ id },
process.env.JWT_SECRET!,
{
expiresIn: process.env.JWT_EXPIRES_IN
}
);
};
export const signup = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const user = await User.create({
name: req.body.name,
email: req.body.email,
password: req.body.password
});
const token = signToken(user._id);
res.status(201).json({
status: 'success',
token,
data: {
user: {
id: user._id,
name: user.name,
email: user.email
}
}
});
} catch (error) {
next(error);
}
};
export const login = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.comparePassword(password))) {
return next(
new AppError('Incorrect email or password', 401)
);
}
const token = signToken(user._id);
res.status(200).json({
status: 'success',
token
});
} catch (error) {
next(error);
}
};
Routes ๐ฃ๏ธ
// src/routes/user.routes.ts
import { Router } from 'express';
import * as userController from '../controllers/user.controller';
import { protect, authorize } from '../middleware/auth.middleware';
const router = Router();
router.post('/signup', userController.signup);
router.post('/login', userController.login);
router.use(protect);
router.get('/me', userController.getMe);
router.patch('/updateMe', userController.updateMe);
router.use(authorize('admin'));
router.route('/')
.get(userController.getAllUsers)
.post(userController.createUser);
router.route('/:id')
.get(userController.getUser)
.patch(userController.updateUser)
.delete(userController.deleteUser);
export default router;
Application Setup ๐
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { connectDB } from './config/database';
import userRoutes from './routes/user.routes';
import { errorHandler } from './middleware/error.middleware';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10kb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use('/api', limiter);
// Routes
app.use('/api/v1/users', userRoutes);
// Error handling
app.use(errorHandler);
// Start server
const PORT = process.env.PORT || 3000;
const start = async () => {
try {
await connectDB();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (error) {
console.error('Error starting server:', error);
process.exit(1);
}
};
start();
Testing ๐งช
// src/__tests__/user.test.ts
import request from 'supertest';
import { app } from '../app';
import { User } from '../models/user.model';
import mongoose from 'mongoose';
beforeAll(async () => {
await mongoose.connect(process.env.MONGODB_URI!);
});
afterAll(async () => {
await mongoose.connection.close();
});
describe('User API', () => {
beforeEach(async () => {
await User.deleteMany({});
});
describe('POST /api/v1/users/signup', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/api/v1/users/signup')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
});
expect(res.status).toBe(201);
expect(res.body.data.user).toHaveProperty('id');
expect(res.body).toHaveProperty('token');
});
});
describe('POST /api/v1/users/login', () => {
it('should login user', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
});
const res = await request(app)
.post('/api/v1/users/login')
.send({
email: 'test@example.com',
password: 'password123'
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});
});
});
Deployment ๐
# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- MONGODB_URI=mongodb://mongo:27017/api
depends_on:
- mongo
mongo:
image: mongo:latest
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
volumes:
mongodb_data:
Best Practices ๐
- Use TypeScript for type safety
- Implement proper error handling
- Use environment variables
- Implement proper logging
- Use proper authentication
- Implement rate limiting
- Use proper validation
- Implement proper testing
- Use proper documentation
- Follow security best practices
Additional Resources
Building a REST API with Express.js requires careful consideration of security, scalability, and maintainability. This guide provides a solid foundation for building production-ready APIs that can handle real-world requirements.