Building a Scalable REST API with Node.js and Express

Learn how to build a production-ready REST API with Node.js, Express, and PostgreSQL. Covers authentication, validation, error handling, and best practices.

Backend & Data: Building a Scalable REST API with Node.js and Express

Introduction

Building a robust REST API is a fundamental skill for backend developers. In this guide, we’ll create a production-ready API using Node.js, Express, and PostgreSQL, covering everything from project setup to deployment.

Why Node.js for APIs?

Node.js excels at building APIs due to:

  • Non-blocking I/O: Handles thousands of concurrent connections efficiently
  • JavaScript everywhere: Share code between frontend and backend
  • Rich ecosystem: npm has packages for virtually everything
  • Fast development: Quick iteration and prototyping

Project Setup

Let’s start by initializing our project:

mkdir my-api
cd my-api
npm init -y
npm install express pg dotenv cors helmet
npm install -D typescript @types/node @types/express nodemon

Essential Packages

  • express: Web framework for building APIs
  • pg: PostgreSQL client for Node.js
  • dotenv: Load environment variables from .env files
  • cors: Enable CORS for cross-origin requests
  • helmet: Security middleware for HTTP headers

Project Structure

Organize your code for maintainability:

src/
├── config/
│   └── database.ts        # Database configuration
├── controllers/
│   └── userController.ts  # Request handlers
├── middlewares/
│   ├── auth.ts           # Authentication middleware
│   └── errorHandler.ts   # Error handling
├── models/
│   └── User.ts           # Data models
├── routes/
│   └── userRoutes.ts     # Route definitions
├── services/
│   └── userService.ts    # Business logic
├── utils/
│   └── validators.ts     # Input validation
└── index.ts              # Entry point

Building the Core API

1. Database Connection

// src/config/database.ts
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

export default pool;

2. Express Server Setup

// src/index.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import userRoutes from './routes/userRoutes';
import { errorHandler } from './middlewares/errorHandler';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/users', userRoutes);

// Error handling
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

3. Creating Routes

// src/routes/userRoutes.ts
import { Router } from 'express';
import { 
  getUsers, 
  getUserById, 
  createUser, 
  updateUser, 
  deleteUser 
} from '../controllers/userController';
import { authenticate } from '../middlewares/auth';

const router = Router();

router.get('/', authenticate, getUsers);
router.get('/:id', authenticate, getUserById);
router.post('/', createUser);
router.put('/:id', authenticate, updateUser);
router.delete('/:id', authenticate, deleteUser);

export default router;

4. Controller Implementation

// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import * as userService from '../services/userService';

export const getUsers = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const users = await userService.getAllUsers();
    res.json({ success: true, data: users });
  } catch (error) {
    next(error);
  }
};

export const createUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = await userService.createUser(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (error) {
    next(error);
  }
};

// ... other controllers

5. Service Layer

// src/services/userService.ts
import pool from '../config/database';
import bcrypt from 'bcrypt';

export const getAllUsers = async () => {
  const result = await pool.query(
    'SELECT id, email, name, created_at FROM users'
  );
  return result.rows;
};

export const createUser = async (userData: any) => {
  const { email, password, name } = userData;
  
  // Hash password
  const hashedPassword = await bcrypt.hash(password, 10);
  
  const result = await pool.query(
    'INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING id, email, name',
    [email, hashedPassword, name]
  );
  
  return result.rows[0];
};

// ... other service methods

Error Handling

Centralized error handling makes debugging easier:

// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';

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,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    success: false,
    message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
};

Authentication with JWT

// src/middlewares/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from './errorHandler';

export const authenticate = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const token = req.headers.authorization?.split(' ')[1];
    
    if (!token) {
      throw new AppError('No token provided', 401);
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = decoded;
    next();
  } catch (error) {
    next(new AppError('Invalid token', 401));
  }
};

Input Validation

Always validate user input:

// src/utils/validators.ts
import Joi from 'joi';

export const userSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  name: Joi.string().min(2).max(50).required(),
});

export const validateUser = (data: any) => {
  const { error, value } = userSchema.validate(data);
  if (error) {
    throw new AppError(error.details[0].message, 400);
  }
  return value;
};

Best Practices

1. Use Environment Variables

Never hardcode sensitive data:

DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapi
DB_USER=postgres
DB_PASSWORD=your_password
JWT_SECRET=your_secret_key
NODE_ENV=development

2. Rate Limiting

Protect your API from abuse:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
});

app.use('/api/', limiter);

3. Request Logging

import morgan from 'morgan';

app.use(morgan('combined'));

4. Database Connection Pooling

Reuse connections for better performance:

const pool = new Pool({
  max: 20, // maximum number of clients
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

5. API Documentation

Use Swagger/OpenAPI:

import swaggerUi from 'swagger-ui-express';
import swaggerDocument from './swagger.json';

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

Testing

Write tests for your API:

// tests/users.test.ts
import request from 'supertest';
import app from '../src/index';

describe('User API', () => {
  it('should create a new user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User',
      });
    
    expect(res.statusCode).toBe(201);
    expect(res.body.success).toBe(true);
    expect(res.body.data).toHaveProperty('id');
  });
});

Deployment

Environment Setup

  1. Set production environment variables
  2. Use a process manager (PM2)
  3. Set up reverse proxy (Nginx)
  4. Enable HTTPS with Let’s Encrypt

Docker Deployment

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

CMD ["node", "dist/index.js"]

Performance Optimization

  1. Caching: Use Redis for frequently accessed data
  2. Pagination: Limit response sizes
  3. Compression: Enable gzip compression
  4. Database Indexing: Index frequently queried columns
  5. Connection Pooling: Reuse database connections

Security Checklist

  • ✅ Use HTTPS in production
  • ✅ Validate all inputs
  • ✅ Hash passwords with bcrypt
  • ✅ Use JWT for authentication
  • ✅ Implement rate limiting
  • ✅ Set security headers with Helmet
  • ✅ Enable CORS appropriately
  • ✅ Keep dependencies updated
  • ✅ Use environment variables for secrets
  • ✅ Log security events

Conclusion

Building a REST API with Node.js and Express is straightforward when you follow best practices. Focus on:

  • Clear project structure
  • Proper error handling
  • Security from the start
  • Comprehensive testing
  • Documentation

The architecture we’ve built is scalable and maintainable, ready for production use.

Next Steps

  • Add WebSocket support for real-time features
  • Implement GraphQL alongside REST
  • Set up CI/CD pipelines
  • Add monitoring and logging (DataDog, LogRocket)
  • Implement API versioning

Resources