Hello World

September 18, 2002 (22y ago)

Building a Production-Ready Express.js REST API: My Journey from Zero to Hero

Hey there! šŸ‘‹ So I've been working on this Express.js backend lately, and honestly, it turned out pretty solid. Thought I'd share the whole journey with you guys. I'm not usually one to write long posts, but this one's worth it - trust me.

What We're Building

Alright, so the goal was simple: build a bulletproof REST API that doesn't fall apart when things get real. We're talking about proper authentication, role-based access, security that actually works, and a database setup that won't give you nightmares.

Tech Stack (because everyone asks):

Project Structure (The Foundation)

server/
ā”œā”€ā”€ src/
│   ā”œā”€ā”€ config/         # Database, Redis config
│   ā”œā”€ā”€ middleware/     # Auth, error handling, validation
│   ā”œā”€ā”€ models/         # User, RefreshToken models
│   ā”œā”€ā”€ routes/         # API endpoints
│   ā”œā”€ā”€ services/       # Business logic
│   └── utils/          # Logger, helpers
ā”œā”€ā”€ scripts/            # Migration, seeding scripts
ā”œā”€ā”€ tests/              # Test files
ā”œā”€ā”€ server.js           # Main application entry
└── .env                # Environment variables

I know, I know - folder structure discussions can get heated. But this one's clean and scales well, so let's go with it.

Database Setup: Azure PostgreSQL (The Real Deal)

Connection Configuration

First things first - connecting to Azure PostgreSQL isn't as straightforward as local development. Here's what actually works:

// src/config/database.js
const { Pool } = require("pg");
 
class Database {
  constructor() {
    this.pool = null;
  }
 
  async connect() {
    const config = {
      host: process.env.DB_HOST,
      port: parseInt(process.env.DB_PORT) || 5432,
      database: process.env.DB_NAME,
      user: process.env.DB_USER,
      password: String(process.env.DB_PASSWORD),
      ssl:
        process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : false,
      max: 20,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
      keepAlive: true,
      keepAliveInitialDelayMillis: 10000,
    };
 
    this.pool = new Pool(config);
 
    // Always test the connection
    const client = await this.pool.connect();
    await client.query("SELECT NOW()");
    client.release();
 
    return this.pool;
  }
}

Pro tip: That SSL configuration is crucial for Azure. Don't skip it.

Environment Variables (.env)

# Database
DB_HOST=rudrserver.postgres.database.azure.com
DB_PORT=5432
DB_NAME=bettererp
DB_USER=rudr
DB_PASSWORD=Meghna_23
DB_SSL=true
 
# JWT
JWT_SECRET=your-super-secret-jwt-key-make-it-long-and-random
JWT_REFRESH_SECRET=another-secret-for-refresh-tokens
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
 
# Server
PORT=3000
NODE_ENV=development
 
# Redis (optional in dev)
REDIS_URL=redis://localhost:6379

Authentication: JWT Done Right

User Model with Proper Password Hashing

// src/models/User.js
const bcrypt = require("bcryptjs");
 
class User {
  static async create(userData) {
    const { email, password, firstName, lastName, role = "user" } = userData;
 
    // Hash password before storing
    const saltRounds = 12;
    const hashedPassword = await bcrypt.hash(password, saltRounds);
 
    const query = `
      INSERT INTO users (email, password, first_name, last_name, role)
      VALUES ($1, $2, $3, $4, $5)
      RETURNING id, email, first_name, last_name, role, created_at
    `;
 
    const result = await database.query(query, [
      email,
      hashedPassword,
      firstName,
      lastName,
      role,
    ]);
 
    return result.rows[0];
  }
 
  static async validatePassword(plainPassword, hashedPassword) {
    return await bcrypt.compare(plainPassword, hashedPassword);
  }
}

JWT Service (The Heart of Auth)

// src/services/authService.js
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");
 
class AuthService {
  generateAccessToken(user) {
    return jwt.sign(
      {
        userId: user.id,
        email: user.email,
        role: user.role,
      },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );
  }
 
  generateRefreshToken(userId) {
    const tokenId = uuidv4();
    return {
      token: jwt.sign({ userId, tokenId }, process.env.JWT_REFRESH_SECRET, {
        expiresIn: process.env.JWT_REFRESH_EXPIRES_IN,
      }),
      tokenId,
    };
  }
 
  async login(email, password) {
    const user = await User.findByEmail(email);
    if (!user) {
      throw new Error("Invalid credentials");
    }
 
    const isValid = await User.validatePassword(password, user.password);
    if (!isValid) {
      throw new Error("Invalid credentials");
    }
 
    const accessToken = this.generateAccessToken(user);
    const { token: refreshToken, tokenId } = this.generateRefreshToken(user.id);
 
    // Store refresh token in database
    await RefreshToken.create({
      userId: user.id,
      tokenId,
      token: refreshToken,
    });
 
    return {
      user: { ...user, password: undefined }, // Never return password
      tokens: { accessToken, refreshToken },
    };
  }
}

Middleware: Security & Authorization

Authentication Middleware

// src/middleware/auth.js
const jwt = require("jsonwebtoken");
 
const authenticateToken = async (req, res, next) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(" ")[1];
 
  if (!token) {
    return res.status(401).json({
      success: false,
      message: "Access token required",
    });
  }
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      message: "Invalid or expired token",
    });
  }
};
 
const requireRole = (roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        message: "Authentication required",
      });
    }
 
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        message: "Insufficient permissions",
      });
    }
 
    next();
  };
};

Error Handling (Because Things Break)

// src/middleware/errorHandler.js
const logger = require("../utils/logger");
 
const errorHandler = (error, req, res, next) => {
  logger.error("Error:", {
    message: error.message,
    stack: error.stack,
    url: req.url,
    method: req.method,
  });
 
  // Validation errors
  if (error.name === "ValidationError") {
    return res.status(400).json({
      success: false,
      message: "Validation failed",
      errors: error.details,
    });
  }
 
  // JWT errors
  if (error.name === "JsonWebTokenError") {
    return res.status(401).json({
      success: false,
      message: "Invalid token",
    });
  }
 
  // Default server error
  res.status(500).json({
    success: false,
    message:
      process.env.NODE_ENV === "production"
        ? "Internal server error"
        : error.message,
  });
};

API Routes: Clean and Organized

Authentication Routes

// src/routes/auth.js
const express = require("express");
const { body, validationResult } = require("express-validator");
const AuthService = require("../services/authService");
 
const router = express.Router();
const authService = new AuthService();
 
// Register endpoint
router.post(
  "/register",
  [
    body("email").isEmail().normalizeEmail(),
    body("password")
      .isLength({ min: 8 })
      .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/),
    body("firstName").trim().isLength({ min: 1 }),
    body("lastName").trim().isLength({ min: 1 }),
  ],
  async (req, res, next) => {
    try {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({
          success: false,
          message: "Validation failed",
          errors: errors.array(),
        });
      }
 
      const result = await authService.register(req.body);
      res.status(201).json({
        success: true,
        message: "User registered successfully",
        data: result,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// Login endpoint
router.post(
  "/login",
  [body("email").isEmail().normalizeEmail(), body("password").exists()],
  async (req, res, next) => {
    try {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({
          success: false,
          message: "Validation failed",
          errors: errors.array(),
        });
      }
 
      const result = await authService.login(req.body.email, req.body.password);
      res.json({
        success: true,
        message: "Login successful",
        data: result,
      });
    } catch (error) {
      next(error);
    }
  }
);

Protected Routes (Role-Based Access)

// src/routes/protected.js
const express = require("express");
const { authenticateToken, requireRole } = require("../middleware/auth");
 
const router = express.Router();
 
// Apply authentication to all routes
router.use(authenticateToken);
 
// User-accessible route
router.get("/profile", (req, res) => {
  res.json({
    success: true,
    message: "Profile data",
    data: { userId: req.user.userId, role: req.user.role },
  });
});
 
// Admin-only routes
router.get("/admin", requireRole(["admin"]), (req, res) => {
  res.json({
    success: true,
    message: "Admin area accessed",
    data: { adminId: req.user.userId },
  });
});
 
router.get("/dashboard", requireRole(["admin"]), (req, res) => {
  res.json({
    success: true,
    message: "Dashboard data",
    data: { stats: "Admin dashboard stats here" },
  });
});

Database Migrations & Seeding

Migration Script

// scripts/migrate.js
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "../.env") });
 
const { connectDB, query, closeDB } = require("../src/config/database");
 
async function runMigrations() {
  try {
    await connectDB();
 
    // Users table
    await query(`
      CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        password VARCHAR(255) NOT NULL,
        first_name VARCHAR(255) NOT NULL,
        last_name VARCHAR(255) NOT NULL,
        role VARCHAR(50) DEFAULT 'user',
        is_active BOOLEAN DEFAULT true,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);
 
    // Refresh tokens table
    await query(`
      CREATE TABLE IF NOT EXISTS refresh_tokens (
        id SERIAL PRIMARY KEY,
        user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
        token_id VARCHAR(255) UNIQUE NOT NULL,
        token TEXT NOT NULL,
        expires_at TIMESTAMP NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);
 
    console.log("āœ… Migrations completed successfully!");
  } catch (error) {
    console.error("āŒ Migration failed:", error);
  } finally {
    await closeDB();
  }
}

Admin User Seeding

// scripts/seed-admin.js
async function seedAdmin() {
  try {
    await connectDB();
 
    const existingAdmin = await User.findByEmail("admin@example.com");
    if (existingAdmin) {
      console.log("Admin user already exists");
      return;
    }
 
    const adminUser = await User.create({
      email: "admin@example.com",
      password: "Admin123!",
      firstName: "Admin",
      lastName: "User",
      role: "admin",
    });
 
    console.log("šŸŽ‰ Admin user created successfully!");
    console.log("šŸ“§ Email: admin@example.com");
    console.log("šŸ”’ Password: Admin123!");
  } catch (error) {
    console.error("āŒ Failed to create admin user:", error.message);
  } finally {
    await closeDB();
  }
}

Security: Because It Matters

Helmet.js and Security Headers

// server.js
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
 
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        scriptSrc: ["'self'"],
        imgSrc: ["'self'", "data:", "https:"],
      },
    },
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
  })
);
 
// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: {
    success: false,
    message: "Too many requests, please try again later.",
  },
});
 
app.use("/api/", limiter);

Input Validation

const { body } = require("express-validator");
 
// Password validation
const passwordValidation = body("password")
  .isLength({ min: 8 })
  .withMessage("Password must be at least 8 characters")
  .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
  .withMessage(
    "Password must contain uppercase, lowercase, number and special character"
  );
 
// Email validation
const emailValidation = body("email")
  .isEmail()
  .normalizeEmail()
  .withMessage("Please provide a valid email");

Logging: Winston for the Win

// src/utils/logger.js
const winston = require("winston");
 
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    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(),
    })
  );
}

Testing the API

Package.json Scripts

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "migrate": "node scripts/migrate.js",
    "seed:admin": "node scripts/seed-admin.js",
    "cleanup": "node scripts/cleanup.js"
  }
}

Testing with PowerShell (Because Windows)

# Register a new user
$user = @{email='test@example.com'; password='TestPass123!'; firstName='Test'; lastName='User'} | ConvertTo-Json
Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/auth/register' -Method POST -Body $user -ContentType 'application/json'
 
# Login and get token
$login = @{email='test@example.com'; password='TestPass123!'} | ConvertTo-Json
$response = Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/auth/login' -Method POST -Body $login -ContentType 'application/json'
$token = $response.data.tokens.accessToken
 
# Access protected route
$headers = @{Authorization="Bearer $token"}
Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/protected/profile' -Method GET -Headers $headers

Redis Integration (Optional but Recommended)

// src/config/redis.js
const redis = require("redis");
 
class RedisClient {
  constructor() {
    this.client = null;
    this.isConnected = false;
  }
 
  async connect() {
    if (process.env.NODE_ENV === "development" && !process.env.REDIS_URL) {
      console.log("Redis disabled in development mode");
      return null;
    }
 
    try {
      this.client = redis.createClient({
        url: process.env.REDIS_URL,
      });
 
      await this.client.connect();
      this.isConnected = true;
      console.log("Redis connected successfully");
      return this.client;
    } catch (error) {
      console.warn(
        "Redis connection failed, continuing without cache:",
        error.message
      );
      return null;
    }
  }
}

Production Considerations

Environment-Based Configuration

// Different configs for different environments
const config = {
  development: {
    logLevel: "debug",
    corsOrigin: "*",
    rateLimit: false,
  },
  production: {
    logLevel: "error",
    corsOrigin: process.env.ALLOWED_ORIGINS?.split(",") || [],
    rateLimit: true,
  },
};
 
const currentConfig = config[process.env.NODE_ENV] || config.development;

Graceful Shutdown

// server.js
let server;
 
async function startServer() {
  try {
    await connectDB();
    await connectRedis();
 
    server = app.listen(PORT, () => {
      logger.info(`Server running on port ${PORT}`);
    });
  } catch (error) {
    logger.error("Failed to start server:", error);
    process.exit(1);
  }
}
 
// Graceful shutdown
process.on("SIGTERM", async () => {
  logger.info("SIGTERM received, shutting down gracefully");
  if (server) {
    server.close(async () => {
      await closeDB();
      await closeRedis();
      process.exit(0);
    });
  }
});

What We've Accomplished

āœ… Solid Authentication: JWT with refresh tokens, bcrypt password hashing
āœ… Role-Based Authorization: Admin and user roles with proper middleware
āœ… Security: Helmet.js, rate limiting, input validation, CORS
āœ… Database: Azure PostgreSQL with connection pooling
āœ… Error Handling: Centralized error middleware with proper logging
āœ… Validation: Express-validator for input sanitization
āœ… Logging: Winston for production-ready logging
āœ… Migration Scripts: Database setup and admin seeding
āœ… Testing: PowerShell scripts for API testing

What's Next?

Final Thoughts

Look, building a REST API isn't rocket science, but doing it right? That takes some thought. This setup gives you a solid foundation that won't embarrass you in production. The authentication is bulletproof, the database connection is stable, and the code is organized in a way that won't make future-you want to rewrite everything.

The best part? It's actually pretty straightforward once you get the patterns down. No over-engineering, no unnecessary complexity - just clean, working code that scales.

Hope this helps someone out there who's trying to build something similar. If you have questions or want to improve something, you know where to find me. Now excuse me while I go grab some coffee and pretend I'm not secretly proud of how this turned out. ā˜•


Tech Stack Summary:

Repository: bettererp