Build a Production REST API with Node.js and Express — Complete Guide 2026
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.tsEntry 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 appDatabase 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 dbValidation 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 routerUser 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
selectonly the fields you need from Prisma queries — never expose password hashes, internal IDs, or audit fields to API consumers. - Store
JWT_SECRETin environment variables and rotate it when secrets are compromised — use a secret manager in production. - Implement rate limiting on auth routes (
/login,/register) withexpress-rate-limitto 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
safeParseAsyncin 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.useranywhere downstream. - Prisma's
selectoption prevents sensitive fields likepasswordfrom being serialized into API responses. - Parallel
Promise.allcalls 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