Complete Guide: Building Production-Ready RESTful APIs with Node.js and Express in 2024
API
Node.js
Express
TypeScript
Backend
Database
Security

Complete Guide: Building Production-Ready RESTful APIs with Node.js and Express in 2024

9 min read

Complete Guide: Building Production-Ready RESTful APIs with Node.js and Express

Table of Contents

  1. Introduction
  2. Project Setup
  3. Database Integration with Prisma
  4. Authentication & Authorization
  5. API Implementation
  6. Validation & Error Handling
  7. Testing
  8. Monitoring & Logging
  9. Deployment & CI/CD
  10. 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

Additional Resources