Building a Production-Ready REST API with Node.js and Express

Building a REST API seems straightforward until you need to handle authentication, validation, error handling, and documentation. This guide covers best practices for production-ready APIs.
Table of Contents
- Project Setup
- Folder Structure
- Database Integration
- Authentication & Authorization
- Error Handling
- Validation
- API Documentation
- Testing
Project Setup
Start with a solid foundation:
mkdir my-api && cd my-api
npm init -y
npm install express dotenv cors helmet compression
npm install -D typescript @types/node @types/express ts-node-dev
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Folder Structure
Organize your code for maintainability:
src/
├── config/ # Configuration files
├── controllers/ # Route controllers
├── middleware/ # Custom middleware
├── models/ # Database models
├── routes/ # API routes
├── services/ # Business logic
├── utils/ # Helper functions
├── validators/ # Input validation
└── app.ts # App setup
Database Integration
Using Prisma for type-safe database access:
// prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// src/config/database.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : []
})
export default prisma
Authentication & Authorization
Implement JWT-based authentication:
// src/middleware/auth.ts
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'
interface JWTPayload {
userId: string
email: string
}
export const authenticate = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'Authentication required' })
}
const payload = jwt.verify(
token,
process.env.JWT_SECRET!
) as JWTPayload
req.user = payload
next()
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
Error Handling
Centralized error handling:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
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: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message
})
}
// Log unexpected errors
console.error('Unexpected error:', err)
res.status(500).json({
status: 'error',
message: 'Internal server error'
})
}
export { AppError }
Validation
Use Zod for runtime validation:
// src/validators/userValidator.ts
import { z } from 'zod'
export const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
})
export const validate = (schema: any) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
})
next()
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
status: 'error',
errors: error.errors
})
}
next(error)
}
}
}
API Documentation
Auto-generate docs with Swagger:
// src/config/swagger.ts
import swaggerJsdoc from 'swagger-jsdoc'
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API',
version: '1.0.0',
description: 'API documentation'
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
}
]
},
apis: ['./src/routes/*.ts']
}
export const swaggerSpec = swaggerJsdoc(options)
Document endpoints in route files:
/**
* @openapi
* /api/users:
* post:
* summary: Create a new user
* tags: [Users]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* name:
* type: string
* password:
* type: string
* responses:
* 201:
* description: User created successfully
*/
router.post('/users', validate(createUserSchema), createUser)
Testing
Write tests with Jest and Supertest:
// tests/users.test.ts
import request from 'supertest'
import app from '../src/app'
import prisma from '../src/config/database'
describe('User API', () => {
beforeEach(async () => {
await prisma.user.deleteMany()
})
afterAll(async () => {
await prisma.$disconnect()
})
describe('POST /api/users', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
})
.expect(201)
expect(res.body).toHaveProperty('id')
expect(res.body.email).toBe('test@example.com')
})
it('should return 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
name: 'Test User',
password: 'password123'
})
.expect(400)
expect(res.body).toHaveProperty('errors')
})
})
})
Security Best Practices
Rate Limiting
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)Helmet for security headers
import helmet from 'helmet' app.use(helmet())CORS configuration
import cors from 'cors' app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(','), credentials: true }))Input Sanitization
import mongoSanitize from 'express-mongo-sanitize' app.use(mongoSanitize())
Performance Optimization
1. Response Compression
import compression from 'compression'
app.use(compression())
2. Caching
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function getCachedData(key: string) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const data = await fetchFromDatabase()
await redis.setex(key, 3600, JSON.stringify(data))
return data
}
3. Database Query Optimization
// Use pagination
const users = await prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
select: {
id: true,
email: true,
name: true
// Don't select sensitive fields like password
}
})
// Use indexes
@@index([email])
@@index([createdAt])
Deployment Checklist
- Environment variables configured
- Database migrations run
- SSL/TLS certificates installed
- Rate limiting enabled
- Logging configured (Winston, Morgan)
- Error tracking (Sentry)
- Health check endpoint
- API documentation deployed
- Load testing completed
- Backup strategy implemented
Monitoring
// Health check endpoint
app.get('/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.status(200).json({ status: 'healthy', timestamp: new Date() })
} catch (error) {
res.status(503).json({ status: 'unhealthy', error: error.message })
}
})
Conclusion
Building production-ready APIs requires attention to:
- Structure: Organized code is maintainable code
- Security: Authentication, validation, and rate limiting
- Errors: Proper error handling and logging
- Testing: Comprehensive test coverage
- Documentation: Clear API docs for consumers
- Performance: Caching and optimization
The complete example code is available on GitHub.
Resources
Questions? Leave a comment below or reach out on Twitter!
