Building REST APIs with Node.js and Express: The Complete Guide
Learn to build production-ready REST APIs with Node.js and Express. Covers routing, middleware, validation, authentication, error handling, and testing best practices.
Express.js is still the most widely used Node.js web framework in 2026 — not because it's flashy, but because it's minimal, flexible, and gets out of your way. When you need an API that's maintainable, testable, and can be understood by any developer who joins your team, Express remains a solid choice.
This guide covers building a real API from scratch with all the pieces that get skipped in most tutorials: proper validation, authentication, error handling, and tests.
Project Setup
mkdir node-api && cd node-api
npm init -y
npm install express zod bcryptjs jsonwebtoken
npm install -D typescript @types/express @types/node @types/bcryptjs @types/jsonwebtoken tsx nodemon
npx tsc --init// tsconfig.json — key settings
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}// package.json scripts
{
"scripts": {
"dev": "nodemon --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}Project Structure
src/
├── index.ts
├── app.ts
├── config/
│ └── env.ts
├── middleware/
│ ├── auth.ts
│ ├── errorHandler.ts
│ └── validateRequest.ts
├── routes/
│ ├── auth.routes.ts
│ └── users.routes.ts
├── controllers/
│ ├── auth.controller.ts
│ └── users.controller.ts
├── services/
│ ├── auth.service.ts
│ └── users.service.ts
└── types/
└── index.ts
App Entry Point
// src/app.ts
import express from 'express'
import { authRouter } from './routes/auth.routes'
import { usersRouter } from './routes/users.routes'
import { errorHandler } from './middleware/errorHandler'
const app = express()
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Health check
app.get('/health', (_, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
// Routes
app.use('/api/v1/auth', authRouter)
app.use('/api/v1/users', usersRouter)
// Error handler — must be last
app.use(errorHandler)
export default app// src/index.ts
import app from './app'
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})Request Validation Middleware
This is the piece most tutorials skip. Every incoming request should be validated before it touches your business logic:
// src/middleware/validateRequest.ts
import { NextFunction, Request, Response } from 'express'
import { ZodSchema } from 'zod'
export function validateRequest(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query,
})
if (!result.success) {
res.status(400).json({
success: false,
error: 'Validation failed',
details: result.error.flatten(),
})
return
}
// Replace req data with parsed/coerced data
req.body = result.data.body
req.params = result.data.params || req.params
req.query = result.data.query || req.query
next()
}
}Authentication: JWT Flow
// src/services/auth.service.ts
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET!
const JWT_EXPIRES_IN = '7d'
export interface TokenPayload {
userId: string
email: string
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export function generateToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
}
export function verifyToken(token: string): TokenPayload {
return jwt.verify(token, JWT_SECRET) as TokenPayload
}// src/middleware/auth.ts
import { NextFunction, Request, Response } from 'express'
import { verifyToken } from '../services/auth.service'
declare global {
namespace Express {
interface Request {
user?: { userId: string; email: string }
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: 'Authentication required' })
return
}
const token = authHeader.split(' ')[1]
try {
const payload = verifyToken(token)
req.user = payload
next()
} catch {
res.status(401).json({ success: false, error: 'Invalid or expired token' })
}
}Routes and Controllers
// src/routes/auth.routes.ts
import { Router } from 'express'
import { z } from 'zod'
import { validateRequest } from '../middleware/validateRequest'
import { register, login } from '../controllers/auth.controller'
export const authRouter = Router()
const registerSchema = z.object({
body: z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2).optional(),
}),
})
const loginSchema = z.object({
body: z.object({
email: z.string().email(),
password: z.string().min(1),
}),
})
authRouter.post('/register', validateRequest(registerSchema), register)
authRouter.post('/login', validateRequest(loginSchema), login)// src/controllers/auth.controller.ts
import { Request, Response, NextFunction } from 'express'
import { db } from '../lib/db'
import { hashPassword, comparePassword, generateToken } from '../services/auth.service'
export async function register(req: Request, res: Response, next: NextFunction) {
try {
const { email, password, name } = req.body
const existing = await db.user.findUnique({ where: { email } })
if (existing) {
res.status(409).json({ success: false, error: 'Email already in use' })
return
}
const hashedPassword = await hashPassword(password)
const user = await db.user.create({
data: { email, password: hashedPassword, name },
select: { id: true, email: true, name: true, createdAt: true },
})
const token = generateToken({ userId: user.id, email: user.email })
res.status(201).json({ success: true, data: { user, token } })
} catch (error) {
next(error)
}
}
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) {
res.status(401).json({ success: false, error: 'Invalid credentials' })
return
}
const valid = await comparePassword(password, user.password)
if (!valid) {
res.status(401).json({ success: false, error: 'Invalid credentials' })
return
}
const token = generateToken({ userId: user.id, email: user.email })
res.json({
success: true,
data: {
user: { id: user.id, email: user.email, name: user.name },
token,
},
})
} catch (error) {
next(error)
}
}Global Error Handler
Don't scatter res.status(500) calls across your controllers. Use a central error handler:
// src/middleware/errorHandler.ts
import { NextFunction, Request, Response } from 'express'
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}
export function errorHandler(
err: Error | AppError,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError && err.isOperational) {
res.status(err.statusCode).json({
success: false,
error: err.message,
})
return
}
// Unexpected errors
console.error('Unhandled error:', err)
res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
})
}Common Mistakes
1. Not handling async errors. Express 4 doesn't automatically catch promise rejections. Either use a wrapper or upgrade to Express 5:
// Wrapper to avoid try/catch in every controller
function asyncHandler(fn: Function) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}2. Returning sensitive data. Never return the full database object — always explicitly select what to expose.
3. Using req.body without validation. Everything from the client is untrusted.
4. No rate limiting. Add express-rate-limit to your auth endpoints before deploying.
5. Synchronous operations in routes. Reading files, heavy computation — move them off the event loop.
Key Takeaways
- Always validate request data with Zod before touching business logic
- Use a layered architecture: routes → controllers → services → database
- Centralize error handling instead of scattering try/catch everywhere
- Never return full database objects — explicitly select what to expose
- Use the
AppErrorpattern to distinguish expected errors from unexpected ones - Add rate limiting and helmet.js before deploying any auth endpoints

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.