Introduction
Every Express tutorial teaches you how to build a server that says "Hello World". Almost none of them teach you what to do next — how to add authentication, validate request bodies, handle errors consistently, and protect against abuse. This post does all of that.
By the end, you'll have a production-ready API foundation you can reuse for any project.
Project structure
src/
├── index.ts
├── middleware/
│ ├── auth.ts
│ ├── validate.ts
│ └── errorHandler.ts
├── routes/
│ └── users.ts
└── lib/
└── jwt.ts
Step 1: Basic Express setup
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
const app = express();
app.use(express.json());
app.use(cors());
app.use(helmet());
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
});
app.use(limiter);
app.listen(3000, () => console.log("API running on port 3000"));
Step 2: JWT authentication middleware
// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token provided" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
(req as any).user = decoded;
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}
Step 3: Request validation with Zod
// middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
Step 4: Centralised error handling
// middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
console.error(err.stack);
res.status(500).json({
error: process.env.NODE_ENV === "production" ? "Internal server error" : err.message,
});
}
Register this as the last middleware in your index.ts — Express knows it's an error handler because it has four parameters.
Step 5: A protected route with validation
// routes/users.ts
import { Router } from "express";
import { z } from "zod";
import { authenticate } from "../middleware/auth";
import { validate } from "../middleware/validate";
const router = Router();
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
});
router.put(
"/profile",
authenticate,
validate(updateProfileSchema),
async (req, res) => {
const { name, bio } = req.body;
// Update database here
res.json({ success: true, name, bio });
}
);
export default router;
Conclusion
With these four layers — rate limiting, JWT auth, Zod validation, and centralised error handling — your Express API is genuinely production-ready. Add a database connection and you have a solid foundation for any project. Every serious API I build starts with exactly this structure.
