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:

file.txt

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:

script.js

class="keyword">import express class="keyword">from class="keyword">class="string">"express";

class="keyword">import helmet class="keyword">from class="keyword">class="string">"helmet";

class="keyword">import cors class="keyword">from class="keyword">class="string">"cors";

class="keyword">import rateLimit class="keyword">from class="keyword">class="string">"express-rate-limit";

class="keyword">class="keyword">const app = express();

class="keyword">class="comment">// Security headers

app.use(helmet());

class="keyword">class="comment">// CORS configuration

app.use(

cors({

origin: process.env.ALLOWED_ORIGINS?.split(class="keyword">class="string">","),

credentials: true,

})

);

class="keyword">class="comment">// Rate limiting

class="keyword">class="keyword">const limiter = rateLimit({

windowMs: 15 * 60 * 1000, class="keyword">class="comment">// 15 minutes

max: 100, class="keyword">class="comment">// limit each IP to 100 requests per windowMs

});

app.use(class="keyword">class="string">"/api/", limiter);

class="keyword">class="comment">// Body parsing

app.use(express.json({ limit: class="keyword">class="string">"10mb" }));

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

RESTful Route Design

Follow REST conventions for intuitive APIs:

script.js

class="keyword">class="comment">// Good REST design

GET /api/users # List all users

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

POST /api/users # Create class="keyword">new user

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

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

class="keyword">DELETE /api/users/:id # Delete user

class="keyword">class="comment">// Nested resources

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

POST /api/users/:id/posts # Create post class="keyword">class="keyword">for user

Error Handling Strategy

Implement centralized error handling:

script.js

class="keyword">class="comment">// Custom error class="keyword">class

class="keyword">class APIError class="keyword">extends Error {

constructor(message, statusCode) {

class="keyword">super(message);

class="keyword">this.statusCode = statusCode;

class="keyword">this.isOperational = true;

Error.captureStackTrace(class="keyword">this, class="keyword">this.constructor);

}

}

class="keyword">class="comment">// Global error handler middleware

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

class="keyword">class="keyword">const statusCode = err.statusCode || 500;

class="keyword">class="keyword">const message = err.isOperational ? err.message : class="keyword">class="string">"Internal server error";

class="keyword">class="keyword">if (process.env.NODE_ENV === class="keyword">class="string">"development") {

res.status(statusCode).json({

status: class="keyword">class="string">"error",

statusCode,

message,

stack: err.stack,

});

} class="keyword">class="keyword">else {

res.status(statusCode).json({

status: class="keyword">class="string">"error",

message,

});

}

});

Input Validation

Validate all incoming data:

script.js

class="keyword">import { z } class="keyword">from class="keyword">class="string">"zod";

class="keyword">class="keyword">const userSchema = z.object({

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

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

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

});

class="keyword">class="keyword">const validateUser = (req, res, next) => {

class="keyword">try {

userSchema.parse(req.body);

next();

} class="keyword">catch (error) {

res.status(400).json({

status: class="keyword">class="string">"error",

message: class="keyword">class="string">"Validation failed",

errors: error.errors,

});

}

};

app.post(class="keyword">class="string">"/api/users", validateUser, createUser);

Database Integration

Use connection pooling for better performance:

script.js

class="keyword">import { Pool } class="keyword">from class="keyword">class="string">"pg";

class="keyword">class="keyword">const pool = class="keyword">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, class="keyword">class="comment">// connection pool size

idleTimeoutMillis: 30000,

});

class="keyword">class="comment">// Example query with error handling

class="keyword">class="keyword">async class="keyword">class="keyword">function getUserById(id) {

class="keyword">class="keyword">const client = class="keyword">class="keyword">await pool.connect();

class="keyword">try {

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

id,

]);

class="keyword">class="keyword">return result.rows[0];

} class="keyword">finally {

client.release();

}

}

Authentication & Authorization

Implement JWT-based authentication:

script.js

class="keyword">import jwt class="keyword">from class="keyword">class="string">"jsonwebtoken";

class="keyword">class="comment">// Generate token

class="keyword">class="keyword">function generateToken(user) {

class="keyword">class="keyword">return jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, {

expiresIn: class="keyword">class="string">"7d",

});

}

class="keyword">class="comment">// Auth middleware

class="keyword">class="keyword">function authenticate(req, res, next) {

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

class="keyword">class="keyword">if (!token) {

class="keyword">class="keyword">return res.status(401).json({

status: class="keyword">class="string">"error",

message: class="keyword">class="string">"No token provided",

});

}

class="keyword">try {

class="keyword">class="keyword">const decoded = jwt.verify(token, process.env.JWT_SECRET);

req.user = decoded;

next();

} class="keyword">catch (error) {

res.status(401).json({

status: class="keyword">class="string">"error",

message: class="keyword">class="string">"Invalid token",

});

}

}

Performance Optimization

Implement caching for frequently accessed data:

script.js

class="keyword">import Redis class="keyword">from class="keyword">class="string">"ioredis";

class="keyword">class="keyword">const redis = class="keyword">new Redis({

host: process.env.REDIS_HOST,

port: process.env.REDIS_PORT,

});

class="keyword">class="comment">// Cache middleware

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

class="keyword">class="keyword">const key = class="keyword">class="string">cache:${req.originalUrl};

class="keyword">class="keyword">const cached = class="keyword">class="keyword">await redis.get(key);

class="keyword">class="keyword">if (cached) {

class="keyword">class="keyword">return res.json(JSON.parse(cached));

}

res.sendResponse = res.json;

res.json = (body) => {

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

res.sendResponse(body);

};

next();

};

class="keyword">class="comment">// Use cache class="keyword">class="keyword">for expensive queries

app.get(class="keyword">class="string">"/api/users", cacheMiddleware(300), getUsers);

Logging & Monitoring

Track API performance and errors:

script.js

class="keyword">import winston class="keyword">from class="keyword">class="string">"winston";

class="keyword">class="keyword">const logger = winston.createLogger({

level: class="keyword">class="string">"info",

format: winston.format.json(),

transports: [

class="keyword">new winston.transports.File({ filename: class="keyword">class="string">"error.log", level: class="keyword">class="string">"error" }),

class="keyword">new winston.transports.File({ filename: class="keyword">class="string">"combined.log" }),

],

});

class="keyword">class="comment">// Request logging middleware

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

class="keyword">class="keyword">const start = Date.now();

res.on(class="keyword">class="string">"finish", () => {

logger.info({

method: req.method,

url: req.url,

status: res.statusCode,

duration: Date.now() - start,

});

});

next();

});

API Documentation

Use Swagger/OpenAPI for documentation:

script.js

class="keyword">import swaggerUi class="keyword">from class="keyword">class="string">"swagger-ui-express";

class="keyword">import swaggerJsdoc class="keyword">from class="keyword">class="string">"swagger-jsdoc";

class="keyword">class="keyword">const options = {

definition: {

openapi: class="keyword">class="string">"3.0.0",

info: {

title: class="keyword">class="string">"My API",

version: class="keyword">class="string">"1.0.0",

description: class="keyword">class="string">"A well-documented API",

},

servers: [{ url: class="keyword">class="string">"http:class="keyword">class="comment">//localhost:3000" }],

},

apis: [class="keyword">class="string">"./src/routes/*.js"],

};

class="keyword">class="keyword">const specs = swaggerJsdoc(options);

app.use(class="keyword">class="string">"/api-docs", swaggerUi.serve, swaggerUi.setup(specs));

Testing Your API

Write comprehensive tests:

script.js

class="keyword">import request class="keyword">from class="keyword">class="string">"supertest";

class="keyword">import { expect } class="keyword">from class="keyword">class="string">"chai";

class="keyword">import app class="keyword">from class="keyword">class="string">"../src/app";

describe(class="keyword">class="string">"User API", () => {

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

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

name: class="keyword">class="string">"John Doe",

email: class="keyword">class="string">"john@example.com",

});

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

expect(res.body).to.have.property(class="keyword">class="string">"id");

});

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

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

name: class="keyword">class="string">"John Doe",

email: class="keyword">class="string">"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! 🚀