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.
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"
}
}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
// 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}`)
})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()
}
}// 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' })
}
}// 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)
}
}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,
})
}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.
AppError pattern to distinguish expected errors from unexpected ones