Build a Production REST API with Node.js and Express — Complete Guide 2026

Sanjeev SharmaSanjeev Sharma
8 min read

Advertisement

Introduction

Why This Matters

Express handles tens of millions of production requests per day across thousands of companies. Its minimalism is a feature — it gives you exactly what you need and nothing you do not. But that minimalism means you have to make deliberate decisions about architecture, validation, authentication, and error handling, or those pieces end up scattered inconsistently across a codebase.

This guide builds a complete, production-ready API from the ground up: TypeScript types throughout, Zod for runtime validation, JWT for authentication, Prisma for database access, and a layered MVC structure that scales as the codebase grows.

Project Setup

mkdir my-api && cd my-api
npm init -y
npm install express dotenv @prisma/client zod jsonwebtoken bcryptjs
npm install --save-dev typescript @types/express @types/node @types/jsonwebtoken @types/bcryptjs ts-node nodemon prisma
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}
{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Project Structure (MVC + Services)

src/
├── controllers/
│   ├── authController.ts
│   └── userController.ts
├── services/
│   ├── authService.ts
│   └── userService.ts
├── routes/
│   ├── authRoutes.ts
│   └── userRoutes.ts
├── middleware/
│   ├── authenticate.ts
│   ├── validate.ts
│   └── errorHandler.ts
├── schemas/
│   └── userSchemas.ts
├── errors.ts
├── lib/
│   └── db.ts
└── index.ts

Entry Point and Middleware Setup

// src/index.ts
import express from 'express'
import dotenv from 'dotenv'
import authRoutes from './routes/authRoutes'
import userRoutes from './routes/userRoutes'
import { errorHandler } from './middleware/errorHandler'
 
dotenv.config()
 
const app = express()
const PORT = process.env.PORT || 3000
 
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
 
// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
 
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
 
// 404 handler — must be before errorHandler
app.use((_req, res) => {
  res.status(404).json({ message: 'Route not found' })
})
 
// Global error handler — must be last
app.use(errorHandler)
 
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})
 
export default app

Database with Prisma

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  name      String
  email     String   @unique
  password  String
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
enum Role {
  USER
  ADMIN
}
// src/lib/db.ts
import { PrismaClient } from '@prisma/client'
 
const db = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
})
 
export default db

Validation Middleware with Zod

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { AnyZodObject, ZodError } from 'zod'
 
export const validate = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    const result = await schema.safeParseAsync({
      body: req.body,
      query: req.query,
      params: req.params,
    })
 
    if (!result.success) {
      return res.status(400).json({
        message: 'Validation error',
        errors: result.error.flatten().fieldErrors,
      })
    }
 
    next()
  }
// src/schemas/userSchemas.ts
import { z } from 'zod'
 
export const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2, 'Name must be at least 2 characters').max(50),
    email: z.string().email('Invalid email address'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
  }),
})
 
export const updateUserSchema = z.object({
  params: z.object({ id: z.string() }),
  body: z.object({
    name: z.string().min(2).max(50).optional(),
    email: z.string().email().optional(),
  }),
})
 
export const paginationSchema = z.object({
  query: z.object({
    page: z.coerce.number().min(1).default(1),
    limit: z.coerce.number().min(1).max(100).default(20),
  }),
})

JWT Authentication Middleware

// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
 
interface JwtPayload {
  userId: string
  email: string
  role: string
}
 
declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload
    }
  }
}
 
export function authenticate(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'No token provided' })
  }
 
  const token = authHeader.split(' ')[1]
 
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload
    req.user = payload
    next()
  } catch {
    res.status(401).json({ message: 'Invalid or expired token' })
  }
}
 
export function requireRole(role: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (req.user?.role !== role) {
      return res.status(403).json({ message: 'Insufficient permissions' })
    }
    next()
  }
}

Auth Controller and Routes

// src/controllers/authController.ts
import { Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import db from '../lib/db'
 
export async function register(req: Request, res: Response, next: NextFunction) {
  try {
    const { name, email, password } = req.body
 
    const existing = await db.user.findUnique({ where: { email } })
    if (existing) return res.status(409).json({ message: 'Email already registered' })
 
    const hashed = await bcrypt.hash(password, 12)
    const user = await db.user.create({
      data: { name, email, password: hashed },
      select: { id: true, name: true, email: true, role: true },
    })
 
    res.status(201).json({ user })
  } catch (err) {
    next(err)
  }
}
 
export async function login(req: Request, res: Response, next: NextFunction) {
  try {
    const { email, password } = req.body
 
    const user = await db.user.findUnique({ where: { email } })
    if (!user) return res.status(401).json({ message: 'Invalid credentials' })
 
    const valid = await bcrypt.compare(password, user.password)
    if (!valid) return res.status(401).json({ message: 'Invalid credentials' })
 
    const token = jwt.sign(
      { userId: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET!,
      { expiresIn: '7d' },
    )
 
    res.json({ token, user: { id: user.id, name: user.name, email: user.email } })
  } catch (err) {
    next(err)
  }
}
// src/routes/userRoutes.ts
import { Router } from 'express'
import { getAllUsers, getUserById, updateUser, deleteUser } from '../controllers/userController'
import { authenticate, requireRole } from '../middleware/authenticate'
import { validate } from '../middleware/validate'
import { updateUserSchema, paginationSchema } from '../schemas/userSchemas'
 
const router = Router()
 
router.get('/', authenticate, validate(paginationSchema), getAllUsers)
router.get('/:id', authenticate, getUserById)
router.put('/:id', authenticate, validate(updateUserSchema), updateUser)
router.delete('/:id', authenticate, requireRole('ADMIN'), deleteUser)
 
export default router

User Service Layer

// src/services/userService.ts
import db from '../lib/db'
 
interface FindAllOptions {
  page: number
  limit: number
}
 
export class UserService {
  async findAll({ page, limit }: FindAllOptions) {
    const skip = (page - 1) * limit
 
    const [users, total] = await Promise.all([
      db.user.findMany({
        skip,
        take: limit,
        select: { id: true, name: true, email: true, role: true, createdAt: true },
        orderBy: { createdAt: 'desc' },
      }),
      db.user.count(),
    ])
 
    return {
      data: users,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
      },
    }
  }
 
  async findById(id: string) {
    return db.user.findUnique({
      where: { id },
      select: { id: true, name: true, email: true, role: true, createdAt: true },
    })
  }
 
  async update(id: string, data: { name?: string; email?: string }) {
    return db.user.update({
      where: { id },
      data,
      select: { id: true, name: true, email: true, role: true },
    })
  }
 
  async delete(id: string) {
    return db.user.delete({ where: { id } })
  }
}

Common Mistakes

Registering the error handler before routes. Express processes middleware in registration order — the error handler must be the last app.use() call. If it is registered before routes, it will never see errors from those routes.

Missing await on async Prisma calls. Prisma methods return Promises — forgetting await returns a pending Promise instead of the resolved value, causing silent incorrect behavior.

Returning password hashes in API responses. Always use select: { password: false } in Prisma queries or explicitly strip the field from the response before serializing.

Not handling jwt.verify rejections. jwt.verify throws synchronously for invalid tokens — it must be wrapped in a try/catch. It also throws for expired tokens, so check err.name === 'TokenExpiredError' to return a helpful message.

Best Practices

  • Use a service layer between controllers and the database — controllers handle HTTP concerns (parsing request, sending response) and services handle business logic.
  • Always select only the fields you need from Prisma queries — never expose password hashes, internal IDs, or audit fields to API consumers.
  • Store JWT_SECRET in environment variables and rotate it when secrets are compromised — use a secret manager in production.
  • Implement rate limiting on auth routes (/login, /register) with express-rate-limit to prevent brute-force attacks.
  • Return consistent JSON shapes across all endpoints: { data, message, errors } — inconsistent shapes make API consumers harder to write.

Key Takeaways

  • Express uses a layered middleware model where order of registration determines execution order — the global error handler must always be last.
  • A service layer separates HTTP parsing (controller) from business logic (service) and database access — this makes both easier to test independently.
  • Zod safeParseAsync in a validation middleware provides structured, field-level error responses without try/catch in every controller.
  • JWT authentication belongs in middleware, not in every route handler — authenticate once, then read req.user anywhere downstream.
  • Prisma's select option prevents sensitive fields like password from being serialized into API responses.
  • Parallel Promise.all calls for count + data queries reduce the pagination response time compared to sequential queries.
  • bcryptjs with a cost factor of 12 provides sufficient password hashing strength for most production workloads in 2026.
  • Rate limiting on authentication endpoints (express-rate-limit) is essential — without it, password brute-force attacks are trivial.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading