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
- Set production environment variables
- Use a process manager (PM2)
- Set up reverse proxy (Nginx)
- 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
- Caching: Use Redis for frequently accessed data
- Pagination: Limit response sizes
- Compression: Enable gzip compression
- Database Indexing: Index frequently queried columns
- 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
