HarshPatel

Ahmedabad, Gujarat
Back to Blog
Express.jsNode.jsREST APIJWTBackendAuthentication

Building a Production-Ready REST API with Express.js: Auth, Validation, and Error Handling

Harsh PatelMay 4, 20263 min read9 views
Building a Production-Ready REST API with Express.js: Auth, Validation, and Error Handling

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.

All Posts
Express.jsNode.jsREST APIJWTBackendAuthentication