Designing a Production-Ready Express and MongoDB API | Roman Humagain

Designing a Production-Ready Express and MongoDB API | Roman Humagain

A practical guide to building production-ready REST APIs with Express and MongoDB: project structure, validation, error handling, indexing, pagination, auth and rate limiting.

Getting an Express endpoint to return JSON takes about four lines. Getting an Express API to survive contact with real traffic, real data and real users takes rather more — and almost none of the difference is exciting. It is validation, error handling, indexes and limits. This is the checklist I work through on every Node backend. Structure it by responsibility The layout that has held up best for me is boring and predictable: src/ models/ Mongoose schemas controllers/ request handling, one function per endpoint routes/ URL to controller wiring middleware/ auth, rate limits, uploads, error handler services/ business logic that outgrows a controller utils/ small shared helpers The rule that keeps it honest: controllers do not contain business logic . A controller reads the request, calls something, and shapes the response. The moment a controller exceeds about fifty lines, the logic inside it wants to be a service — where it can be tested without faking an HTTP request. Validate at the boundary Every field arriving from a client is untrusted, including ones your own frontend sends. Mongoose schema validation is a good backstop but a poor front door — it runs late and its error messages are not written for API consumers. Validate explicitly at the edge, and be specific about what is allowed: const ALLOWED = ["title", "description", "category", "isActive"]; const pick = (body) => Object.fromEntries( Object.entries(body).filter(([k]) => ALLOWED.includes(k)) ); An allowlist rather than a denylist matters. Spreading req.body straight into an update lets a caller set fields you never intended to expose — role, isVerified, order. This is mass assignment, and it is one of the easiest serious bugs to ship. Handle errors in one place Scattering try/catch through every controller produces inconsistent responses and a lot of noise. Centralise it. // wrap async handlers so rejections reach the error middleware const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // one error shape for the whole API app.use((err, req, res, next) => { const status = err.status || 500; if (status >= 500) console.error(err); res.status(status).json({ message: status >= 500 ? "Something went wrong" : err.message, }); }); Note the asymmetry: client errors return their message, server errors return a generic one. Internal error text leaks stack traces, driver messages and occasionally credentials. Index before you need to MongoDB will happily collection-scan a million documents and stay fast enough in development, where you have two hundred. The cliff arrives in production. Any field you filter or sort on regularly needs an index: projectSchema.index({ isActive: 1, order: 1 }); projectSchema.index({ slug: 1 }, { unique: true }); Compound index order follows the ESR rule — E quality fields first, then S ort fields, then R ange fields. When a query is slow, run it through .explain("executionStats") and look at totalDocsExamined against nReturned. If you are examining thousands to return ten, you are missing an index. Paginate everything that grows Any endpoint returning a list needs a limit, even if today it returns six rows. Offset pagination with skip is simple but degrades on large collections, because the database must walk everything it skips. Cursor pagination stays fast at any depth: const limit = Math.min(Number(req.query.limit) || 20, 100); const query = { isActive: true }; if (req.query.cursor) query._id = { $lt: req.query.cursor }; const items = await Project.find(query).sort({ _id: -1 }).limit(limit + 1); const hasMore = items.length > limit; Note the clamp on limit. Without it, ?limit=100000 is a free denial-of-service. Return only what the client needs Fetching whole documents and trimming them in JavaScript wastes bandwidth in both directions, and it is how password hashes end up in API responses. Project in the query, and prefer .lean() for reads that do not need Mongoose documents: const users = await User.find({ isActive: true }) .select("name email createdAt") .lean(); Authentication and rate limiting For JWTs: keep the payload minimal, keep expiry short, and verify on every protected request rather than trusting a decoded copy. If a token can grant admin access, its lifetime is your worst-case exposure window. Rate limits belong on more than the login route. Tier them by cost: const publicRead = rateLimit({ windowMs: 5 * 60 * 1000, max: 300 }); const authAttempts = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }); Anything expensive — file uploads, search, anything calling a paid API — needs its own tighter limit. Configuration and secrets Read every environment variable once at startup and fail loudly if a required one is missing. A server that boots with an undefined database URL and dies on the first request is far harder to debug than one that refuses to start. const required = ["MONGO_URI", "JWT_SECRET"]; const missing = required.filter((k) => !process.env[k]); if (missing.length) { throw new Error(`Missing env vars: ${missing.join(", ")}`); } The short version Controllers stay thin; logic lives in services Allowlist input fields — never spread req.body into an update One error handler, and never leak internal messages Index what you filter and sort on, and verify with explain Paginate and clamp every list endpoint Project fields in the query, not afterwards Validate configuration at startup None of it is clever. All of it is the difference between an API that works and one that keeps working.

Continue to Roman Humagain