Back to blog

Building Scalable REST APIs with Node.js and Express

5 min readBy Mustafa Akkaya
#Node.js#Express#REST API#Backend

Building robust and scalable REST APIs is a fundamental skill for full-stack developers. In this guide, we'll explore best practices for creating production-ready APIs using Node.js and Express.

Why Node.js for APIs?

Node.js excels at building APIs due to:

- Non-blocking I/O - Handles thousands of concurrent connections

- JavaScript Everywhere - Same language for frontend and backend

- Rich Ecosystem - NPM provides countless packages

- Fast Development - Rapid prototyping and iteration

- Scalability - Easy to scale horizontally

Project Structure Best Practices

Organize your API for maintainability:

src/

├── config/ # Configuration files

├── controllers/ # Request handlers

├── middleware/ # Custom middleware

├── models/ # Data models

├── routes/ # API routes

├── services/ # Business logic

├── utils/ # Helper functions

└── validators/ # Input validation

Essential Middleware Stack

Set up your Express app with critical middleware:

javascript

import express from "express";

import helmet from "helmet";

import cors from "cors";

import rateLimit from "express-rate-limit";

const app = express();

// Security headers

app.use(helmet());

// CORS configuration

app.use(

cors({

origin: process.env.ALLOWED_ORIGINS?.split(","),

credentials: true,

})

);

// 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);

// Body parsing

app.use(express.json({ limit: "10mb" }));

app.use(express.urlencoded({ extended: true }));

RESTful Route Design

Follow REST conventions for intuitive APIs:

javascript

// Good REST design

GET /api/users # List all users

GET /api/users/:id # Get specific user

POST /api/users # Create new user

PUT /api/users/:id # Update user (full)

PATCH /api/users/:id # Update user (partial)

DELETE /api/users/:id # Delete user

// Nested resources

GET /api/users/:id/posts # Get user's posts

POST /api/users/:id/posts # Create post for user

Error Handling Strategy

Implement centralized error handling:

javascript

// Custom error class

class APIError extends Error {

constructor(message, statusCode) {

super(message);

this.statusCode = statusCode;

this.isOperational = true;

Error.captureStackTrace(this, this.constructor);

}

}

// Global error handler middleware

app.use((err, req, res, next) => {

const statusCode = err.statusCode || 500;

const message = err.isOperational ? err.message : "Internal server error";

if (process.env.NODE_ENV === "development") {

res.status(statusCode).json({

status: "error",

statusCode,

message,

stack: err.stack,

});

} else {

res.status(statusCode).json({

status: "error",

message,

});

}

});

Input Validation

Validate all incoming data:

javascript

import { z } from "zod";

const userSchema = z.object({

name: z.string().min(2).max(100),

email: z.string().email(),

age: z.number().int().min(18).max(120).optional(),

});

const validateUser = (req, res, next) => {

try {

userSchema.parse(req.body);

next();

} catch (error) {

res.status(400).json({

status: "error",

message: "Validation failed",

errors: error.errors,

});

}

};

app.post("/api/users", validateUser, createUser);

Database Integration

Use connection pooling for better performance:

javascript

import { Pool } from "pg";

const pool = new Pool({

host: process.env.DB_HOST,

port: process.env.DB_PORT,

database: process.env.DB_NAME,

user: process.env.DB_USER,

password: process.env.DB_PASSWORD,

max: 20, // connection pool size

idleTimeoutMillis: 30000,

});

// Example query with error handling

async function getUserById(id) {

const client = await pool.connect();

try {

const result = await client.query("SELECT * FROM users WHERE id = $1", [

id,

]);

return result.rows[0];

} finally {

client.release();

}

}

Authentication & Authorization

Implement JWT-based authentication:

javascript

import jwt from "jsonwebtoken";

// Generate token

function generateToken(user) {

return jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, {

expiresIn: "7d",

});

}

// Auth middleware

function authenticate(req, res, next) {

const token = req.headers.authorization?.split(" ")[1];

if (!token) {

return res.status(401).json({

status: "error",

message: "No token provided",

});

}

try {

const decoded = jwt.verify(token, process.env.JWT_SECRET);

req.user = decoded;

next();

} catch (error) {

res.status(401).json({

status: "error",

message: "Invalid token",

});

}

}

Performance Optimization

Implement caching for frequently accessed data:

javascript

import Redis from "ioredis";

const redis = new Redis({

host: process.env.REDIS_HOST,

port: process.env.REDIS_PORT,

});

// Cache middleware

const cacheMiddleware = (duration) => async (req, res, next) => {

const key = cache:${req.originalUrl};

const cached = await redis.get(key);

if (cached) {

return res.json(JSON.parse(cached));

}

res.sendResponse = res.json;

res.json = (body) => {

redis.setex(key, duration, JSON.stringify(body));

res.sendResponse(body);

};

next();

};

// Use cache for expensive queries

app.get("/api/users", cacheMiddleware(300), getUsers);

Logging & Monitoring

Track API performance and errors:

javascript

import winston from "winston";

const logger = winston.createLogger({

level: "info",

format: winston.format.json(),

transports: [

new winston.transports.File({ filename: "error.log", level: "error" }),

new winston.transports.File({ filename: "combined.log" }),

],

});

// Request logging middleware

app.use((req, res, next) => {

const start = Date.now();

res.on("finish", () => {

logger.info({

method: req.method,

url: req.url,

status: res.statusCode,

duration: Date.now() - start,

});

});

next();

});

API Documentation

Use Swagger/OpenAPI for documentation:

javascript

import swaggerUi from "swagger-ui-express";

import swaggerJsdoc from "swagger-jsdoc";

const options = {

definition: {

openapi: "3.0.0",

info: {

title: "My API",

version: "1.0.0",

description: "A well-documented API",

},

servers: [{ url: "http://localhost:3000" }],

},

apis: ["./src/routes/*.js"],

};

const specs = swaggerJsdoc(options);

app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));

Testing Your API

Write comprehensive tests:

javascript

import request from "supertest";

import { expect } from "chai";

import app from "../src/app";

describe("User API", () => {

it("should create a new user", async () => {

const res = await request(app).post("/api/users").send({

name: "John Doe",

email: "john@example.com",

});

expect(res.status).to.equal(201);

expect(res.body).to.have.property("id");

});

it("should return 400 for invalid email", async () => {

const res = await request(app).post("/api/users").send({

name: "John Doe",

email: "invalid-email",

});

expect(res.status).to.equal(400);

});

});

Deployment Checklist

Before going to production:

- ✅ Use environment variables for config

- ✅ Enable HTTPS/TLS

- ✅ Set up rate limiting

- ✅ Implement proper logging

- ✅ Add health check endpoints

- ✅ Configure CORS properly

- ✅ Use production database

- ✅ Set up monitoring (e.g., PM2, New Relic)

- ✅ Enable compression

- ✅ Document your API

Conclusion

Building scalable REST APIs requires careful planning and adherence to best practices. Focus on security, performance, and maintainability from day one. The patterns shown here will help you create APIs that can grow with your application.

Happy coding! 🚀