Complete Guide: Building Production-Ready RESTful APIs with Node.js and Express in 2024
Complete Guide: Building Production-Ready RESTful APIs with Node.js and Express
Table of Contents
- Introduction
- Project Setup
- Database Integration with Prisma
- Authentication & Authorization
- API Implementation
- Validation & Error Handling
- Testing
- Monitoring & Logging
- Deployment & CI/CD
- Best Practices & Security
Introduction
Modern web applications require robust, secure, and scalable APIs. This guide will walk you through building a production-ready RESTful API using:
- Node.js & Express for the server
- TypeScript for type safety
- Prisma for database operations
- JWT for authentication
- Jest for testing
- Winston for logging
- Docker for containerization
Project Setup
Initial Setup
# Create project directory
mkdir modern-express-api
cd modern-express-api
# Initialize project
npm init -y
# Install core dependencies
npm install express dotenv cors helmet express-validator prisma @prisma/client
npm install jsonwebtoken bcryptjs winston express-rate-limit compression
# Install development dependencies
npm install -D typescript @types/express @types/node @types/jsonwebtoken
npm install -D @types/bcryptjs jest @types/jest supertest ts-node-dev
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# Initialize TypeScript
npx tsc --init
Project Structure
modern-express-api/
βββ src/
β βββ config/
β β βββ database.ts
β β βββ logger.ts
β β βββ swagger.ts
β βββ controllers/
β β βββ authController.ts
β β βββ userController.ts
β βββ middleware/
β β βββ auth.ts
β β βββ errorHandler.ts
β β βββ rateLimiter.ts
β β βββ validation.ts
β βββ models/
β β βββ prisma/
β β βββ schema.prisma
β βββ routes/
β β βββ authRoutes.ts
β β βββ userRoutes.ts
β βββ services/
β β βββ authService.ts
β β βββ userService.ts
β βββ types/
β β βββ index.ts
β βββ utils/
β β βββ passwords.ts
β β βββ tokens.ts
β βββ app.ts
β βββ server.ts
βββ tests/
β βββ integration/
β β βββ auth.test.ts
β βββ unit/
β βββ userService.test.ts
βββ .env
βββ .env.example
βββ docker-compose.yml
βββ Dockerfile
βββ jest.config.js
βββ package.json
βββ tsconfig.json
Configuration Files
tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
.env.example
NODE_ENV=development
PORT=3000
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=24h
REDIS_URL=redis://localhost:6379
Database Integration with Prisma
Prisma Schema (src/models/prisma/schema.prisma)
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
enum Role {
USER
ADMIN
}
Database Configuration (src/config/database.ts)
import { PrismaClient } from "@prisma/client";
import logger from "./logger";
const prisma = new PrismaClient({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "info" },
{ emit: "event", level: "warn" },
],
});
prisma.$on("query", (e) => {
logger.debug("Query: " + e.query);
logger.debug("Duration: " + e.duration + "ms");
});
prisma.$on("error", (e) => {
logger.error("Prisma Error: " + e.message);
});
export default prisma;
Authentication & Authorization
JWT Authentication (src/middleware/auth.ts)
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { ApiError } from "../types";
import { config } from "../config";
export interface AuthRequest extends Request {
user?: {
id: string;
role: string;
};
}
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
try {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
throw new ApiError(401, "Authentication required");
}
const decoded = jwt.verify(token, config.jwtSecret) as {
id: string;
role: string;
};
req.user = decoded;
next();
} catch (error) {
next(new ApiError(401, "Invalid or expired token"));
}
};
export const authorize = (...roles: string[]) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
throw new ApiError(403, "Insufficient permissions");
}
next();
};
};
Auth Service (src/services/authService.ts)
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { ApiError } from "../types";
import { config } from "../config";
export class AuthService {
static async register(userData: {
email: string;
password: string;
name: string;
}) {
const existingUser = await prisma.user.findUnique({
where: { email: userData.email },
});
if (existingUser) {
throw new ApiError(400, "Email already registered");
}
const hashedPassword = await bcrypt.hash(userData.password, 12);
const user = await prisma.user.create({
data: {
...userData,
password: hashedPassword,
},
select: {
id: true,
email: true,
name: true,
role: true,
},
});
const token = this.generateToken(user.id, user.role);
return { user, token };
}
static async login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
throw new ApiError(401, "Invalid credentials");
}
const token = this.generateToken(user.id, user.role);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
token,
};
}
private static generateToken(userId: string, role: string): string {
return jwt.sign({ id: userId, role }, config.jwtSecret, {
expiresIn: config.jwtExpiresIn,
});
}
}
API Implementation
User Controller (src/controllers/userController.ts)
import { Request, Response, NextFunction } from "express";
import { UserService } from "../services/userService";
import { validationResult } from "express-validator";
import { ApiError } from "../types";
export class UserController {
static async getUsers(req: Request, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const search = req.query.search as string;
const users = await UserService.getUsers({ page, limit, search });
res.json({
status: "success",
data: users,
});
} catch (error) {
next(error);
}
}
static async getUserById(req: Request, res: Response, next: NextFunction) {
try {
const user = await UserService.getUserById(req.params.id);
if (!user) {
throw new ApiError(404, "User not found");
}
res.json({
status: "success",
data: user,
});
} catch (error) {
next(error);
}
}
static async updateUser(req: Request, res: Response, next: NextFunction) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
throw new ApiError(400, "Validation error", errors.array());
}
const user = await UserService.updateUser(req.params.id, req.body);
res.json({
status: "success",
data: user,
});
} catch (error) {
next(error);
}
}
static async deleteUser(req: Request, res: Response, next: NextFunction) {
try {
await UserService.deleteUser(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
}
Validation & Error Handling
Request Validation (src/middleware/validation.ts)
import { body, param, query } from "express-validator";
export const createUserValidation = [
body("email").isEmail().normalizeEmail().withMessage("Invalid email address"),
body("name")
.trim()
.isLength({ min: 2, max: 50 })
.withMessage("Name must be between 2 and 50 characters"),
body("password")
.isLength({ min: 8 })
.matches(/^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*])/)
.withMessage(
"Password must be at least 8 characters and contain uppercase, lowercase, number and special character"
),
];
export const updateUserValidation = [
param("id").isUUID().withMessage("Invalid user ID"),
body("email").optional().isEmail().normalizeEmail(),
body("name").optional().trim().isLength({ min: 2, max: 50 }),
];
export const getUsersValidation = [
query("page").optional().isInt({ min: 1 }),
query("limit").optional().isInt({ min: 1, max: 100 }),
query("search").optional().trim().isLength({ min: 1 }),
];
Error Handler (src/middleware/errorHandler.ts)
import { Request, Response, NextFunction } from "express";
import { ApiError } from "../types";
import logger from "../config/logger";
export const errorHandler = (
error: Error | ApiError,
req: Request,
res: Response,
next: NextFunction
) => {
logger.error(error);
if (error instanceof ApiError) {
return res.status(error.statusCode).json({
status: "error",
message: error.message,
details: error.details,
code: error.statusCode,
});
}
// Prisma error handling
if (error.name === "PrismaClientKnownRequestError") {
return res.status(400).json({
status: "error",
message: "Database operation failed",
code: 400,
});
}
// Default error
return res.status(500).json({
status: "error",
message: "Internal server error",
code: 500,
});
};
Testing
Integration Test (tests/integration/auth.test.ts)
import request from "supertest";
import app from "../../src/app";
import prisma from "../../src/config/database";
describe("Auth Endpoints", () => {
beforeAll(async () => {
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
describe("POST /api/v1/auth/register", () => {
it("should register a new user", async () => {
const res = await request(app).post("/api/v1/auth/register").send({
email: "test@example.com",
password: "Test123!@#",
name: "Test User",
});
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty("token");
expect(res.body.data.user).toHaveProperty("id");
expect(res.body.data.user.email).toBe("test@example.com");
});
it("should not register with invalid data", async () => {
const res = await request(app).post("/api/v1/auth/register").send({
email: "invalid-email",
password: "weak",
name: "T",
});
expect(res.status).toBe(400);
expect(res.body).toHaveProperty("details");
});
});
});
Unit Test (tests/unit/userService.test.ts)
import { UserService } from "../../src/services/userService";
import prisma from "../../src/config/database";
import { ApiError } from "../../src/types";
jest.mock("../../src/config/database", () => ({
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
}));
describe("UserService", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("getUsers", () => {
it("should return paginated users", async () => {
const mockUsers = [
{ id: "1", name: "User 1", email: "user1@example.com" },
{ id: "2", name: "User 2", email: "user2@example.com" },
];
(prisma.user.findMany as jest.Mock).mockResolvedValue(mockUsers);
const result = await UserService.getUsers({ page: 1, limit: 10 });
expect(result.users).toEqual(mockUsers);
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 0,
take: 10,
})
);
});
});
});
Monitoring & Logging
Logger Configuration (src/config/logger.ts)
import winston from "winston";
import { config } from ".";
const logger = winston.createLogger({
level: config.nodeEnv === "production" ? "info" : "debug",
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 (config.nodeEnv !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
})
);
}
export default logger;
Deployment & CI/CD
Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/prisma ./prisma
EXPOSE 3000
CMD ["npm", "start"]
docker-compose.yml
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://user:password@db:5432/mydb
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
db:
image: postgres:14-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Best Practices & Security
Security Middleware Configuration
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import compression from "compression";
// Rate limiting
export 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, please try again later",
});
// Security headers
export const securityMiddleware = [helmet(), compression(), limiter];
// CORS configuration
export const corsOptions = {
origin: process.env.ALLOWED_ORIGINS?.split(",") || "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["Content-Range", "X-Content-Range"],
credentials: true,
maxAge: 600,
};
Conclusion
This comprehensive guide covers all aspects of building a production-ready RESTful API with Node.js and Express. Remember to:
- Keep dependencies updated
- Regularly audit your security
- Monitor performance
- Write comprehensive tests
- Document your API
- Follow REST best practices