If you've followed my MongoDB setup guide, you have a database. Now we need an API to put in front of it. This is the production REST API stack I run in 2026: Node.js, Express, Mongoose, JWT auth, Zod validation, Redis-backed rate limiting, and centralized error handling.
Everything in this guide is patterns I ship at vCardly and Himaal Pure — real production traffic, real failure modes.
Prerequisites
- Node.js 20+ and npm/pnpm.
- A running MongoDB instance (local, Atlas, or VPS — see my MongoDB setup guide).
- Optional but recommended: Redis (for rate limiting and caching).
Step 1 — Initialize the project
mkdir my-api && cd my-api
npm init -y
npm install express mongoose dotenv cors helmet morgan zod jsonwebtoken bcrypt express-rate-limit
npm install -D typescript @types/node @types/express @types/cors @types/morgan @types/jsonwebtoken @types/bcrypt tsx
Initialize TypeScript:
npx tsc --init
Edit tsconfig.json — minimum I use:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Step 2 — Folder structure
src/
├── config/
│ ├── db.ts # Mongoose connection
│ └── env.ts # Validated env vars
├── middleware/
│ ├── auth.ts # JWT auth
│ ├── validate.ts # Zod request validator
│ └── error.ts # Centralized error handler
├── modules/
│ └── users/
│ ├── user.model.ts
│ ├── user.routes.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ └── user.schema.ts # Zod schemas
├── utils/
│ └── logger.ts
├── app.ts # Express app (no listen)
└── index.ts # Entry — calls app.listen
The split between app.ts and index.ts matters: app.ts exports the Express instance so you can import it directly in tests without spinning up a server.
Step 3 — The Express app with sensible defaults
src/app.ts:
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import rateLimit from "express-rate-limit";
import userRoutes from "./modules/users/user.routes";
import { errorHandler } from "./middleware/error";
export const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(","), credentials: true }));
app.use(express.json({ limit: "100kb" }));
app.use(morgan("tiny"));
app.use(rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
}));
app.get("/health", (_req, res) => res.json({ ok: true }));
app.use("/api/users", userRoutes);
app.use(errorHandler);
What's defended here:
helmetsets ~12 security headers (CSP, HSTS, X-Frame-Options, etc.) you'd otherwise forget.corswith an explicit origin allow-list — neverorigin: "*"in production.express.json({ limit: "100kb" })— caps payload size so attackers can't ship 10 MB JSON.rateLimit— 100 req/min per IP, plenty for a real user, painful for a scraper.
Step 4 — Validate environment variables on boot
Bugs from missing env vars are the worst kind — they crash in production at 3 a.m. Catch them at startup with Zod:
// src/config/env.ts
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(4000),
MONGODB_URI: z.string().url(),
JWT_SECRET: z.string().min(32),
CORS_ORIGIN: z.string(),
});
export const env = Env.parse(process.env);
If anything is missing, the process refuses to start. Better than a 500 error two days later.
Step 5 — A typed Mongoose model
src/modules/users/user.model.ts:
import { Schema, model, InferSchemaType } from "mongoose";
const userSchema = new Schema({
email: { type: String, required: true, unique: true, lowercase: true, index: true },
passwordHash: { type: String, required: true, select: false },
name: { type: String, required: true },
role: { type: String, enum: ["user", "admin"], default: "user" },
}, { timestamps: true });
export type User = InferSchemaType<typeof userSchema>;
export const UserModel = model("User", userSchema);
select: false on passwordHash means it never leaks in .find() responses unless you opt in. One less footgun.
Step 6 — Validate every request body
src/middleware/validate.ts:
import type { Request, Response, NextFunction } from "express";
import type { ZodSchema } from "zod";
export const validate = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
req.body = parsed.data;
next();
};
src/modules/users/user.schema.ts:
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
name: z.string().min(1).max(80),
});
Step 7 — JWT authentication
src/middleware/auth.ts:
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
import { env } from "../config/env";
export interface AuthRequest extends Request {
user?: { id: string; role: string };
}
export function auth(req: AuthRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) return res.status(401).json({ error: "Unauthorized" });
try {
const payload = jwt.verify(header.slice(7), env.JWT_SECRET) as { id: string; role: string };
req.user = payload;
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
Step 8 — Centralized error handling
Every throw in your async handlers ends up here:
// src/middleware/error.ts
import type { Request, Response, NextFunction } from "express";
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
console.error(err);
const status = err.status ?? 500;
res.status(status).json({
error: err.message ?? "Internal Server Error",
});
}
Use express-async-errors or wrap async handlers so thrown promises propagate to this middleware.
Step 9 — Putting it together (register + login)
// src/modules/users/user.controller.ts
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { UserModel } from "./user.model";
import { env } from "../../config/env";
import type { Request, Response } from "express";
export async function register(req: Request, res: Response) {
const { email, password, name } = req.body;
const passwordHash = await bcrypt.hash(password, 12);
const user = await UserModel.create({ email, passwordHash, name });
res.status(201).json({ id: user._id, email: user.email });
}
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
const user = await UserModel.findOne({ email }).select("+passwordHash");
if (!user) return res.status(401).json({ error: "Invalid credentials" });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "Invalid credentials" });
const token = jwt.sign({ id: user._id, role: user.role }, env.JWT_SECRET, { expiresIn: "7d" });
res.json({ token });
}
Production checklist before you ship
- Set
NODE_ENV=production— Express enables internal optimizations and disables stack traces in responses. - Use a process manager (
pm2orsystemd) so the API restarts if it crashes. - Behind NGINX with HTTPS — never expose Node.js directly. See my DigitalOcean deploy guide.
- Centralized logs —
pinofor structured JSON, ship to a log service or justjournalctl. - Monitoring — Uptime Robot or BetterStack on
/health. - Redis-backed rate limiter (
rate-limit-redis) if you run multiple Node processes. - Sentry or similar for error tracking.
Frequently asked questions
Express vs Fastify vs Hono in 2026?
Express is still the safest pick — the ecosystem is enormous, every middleware exists, every dev knows it. Fastify is faster but the gap shrinks once you add real middleware. Hono is great for edge runtimes (Cloudflare Workers). For most Node.js apps, Express is the right default.
JWT vs sessions — which is better?
Sessions (server-side, cookie-based) for browser apps you control. JWTs for cross-service auth, mobile apps, or APIs consumed by third parties. Both are fine; pick the one that matches your boundary.
Should I use TypeScript on the backend?
Yes. The shared types between API and frontend alone justify it.
How do I test this API?
Vitest + Supertest. Import app directly (which is why app.ts doesn't call .listen) and hit endpoints in-process — no real network, fast feedback.
Conclusion
That's the production REST API stack I run. It's deliberately boring — Express, Mongoose, JWT, Zod. Boring means it works, every contributor knows it, and you can hire for it.
Want help shipping a backend? Get in touch.