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

Dec 10, 2024·
Educator Recruitment, Retention, and Effectiveness
Educator Recruitment, Retention, and Effectiveness
· 5 min read
blog

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

  1. Project Setup
  2. Folder Structure
  3. Database Integration
  4. Authentication & Authorization
  5. Error Handling
  6. Validation
  7. API Documentation
  8. 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

  1. 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)
    
  2. Helmet for security headers

    import helmet from 'helmet'
    app.use(helmet())
    
  3. CORS configuration

    import cors from 'cors'
    app.use(cors({
      origin: process.env.ALLOWED_ORIGINS?.split(','),
      credentials: true
    }))
    
  4. 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!

Educator Recruitment, Retention, and Effectiveness
Authors
The Educator Recruitment, Retention, and Effectiveness center is dedicated to improving teacher recruitment, retention, and effectiveness across Missouri through data-driven research and policy engagement.