All posts
Node.jsMongoDBBackend

Best Practices for Building a REST API with Node.js and MongoDB

CodeYug15 August 20268 min read
Best Practices for Building a REST API with Node.js and MongoDB

Building a quick Express server takes about 5 minutes. Building a secure, scalable REST API that can handle 10,000 requests per minute without melting, leaking data, or turning into unmaintainable spaghetti code is a very different problem.

I have worked on Node.js APIs that started clean and devolved into chaos as the team scaled. The patterns below are the ones I reach for on every production project now. They prevent 90% of the technical debt that kills fast-growing Node.js APIs.

1. Project Structure is the Foundation

The most common mistake in Express apps is putting everything inside route handlers. Route handlers that contain database queries, business logic, email sending, and response formatting are a sign of an API heading toward disaster.

Separate your code into 3 clear layers:

  • Controllers: Handle HTTP requests and responses only. Extract parameters, call a service, return the result. No business logic here.
  • Services: Contain all business logic. This is where rules like "a user can only have one active subscription" or "an order cannot be cancelled after shipping" live. Services are also where your unit tests target.
  • Models: Define your Mongoose schemas and handle database interactions. A model method can wrap a complex query, but it should not contain business rules.

This separation means you can test your business logic in services without spinning up an HTTP server or connecting to a database. It also means a new developer can find where a specific rule is enforced in seconds rather than hours.

2. Security is Not Optional

An API without proper security is not an MVP, it is a liability. These 4 security layers are non-negotiable for anything facing the public internet:

  1. Helmet.js: One line of middleware that sets a dozen HTTP security headers correctly. It prevents clickjacking, XSS attacks via headers, and MIME type sniffing. Just use it.
  2. express-rate-limit: Without rate limiting, your API is vulnerable to brute-force attacks on login endpoints and denial-of-service via request flooding. A simple configuration limits each IP to 100 requests per 15 minutes on sensitive routes.
  3. Input Validation with Zod or Joi: Never trust client-supplied data. Validate every request body, query parameter, and URL segment against a strict schema before it touches your database. Zod integrates beautifully with TypeScript and gives you automatic type inference from your validation schemas.
  4. bcrypt for passwords: Store nothing raw. Hash passwords with bcrypt using a salt round of at least 12. The higher the salt round, the more expensive the hash computation, which directly slows down brute-force attacks on leaked databases.
"A breach that exposes bcrypt-hashed passwords with salt round 12 will take an attacker months to crack even a fraction of them on modern hardware. Plain text or MD5 passwords are compromised in seconds."

3. MongoDB Optimization

MongoDB is fast by default, but bad schema design and unindexed queries will bring it to its knees as your data grows. The two most impactful improvements you can make:

Indexes: Create an index on every field you query frequently. A query on an unindexed field performs a full collection scan, reading every document. A proper index turns a 500ms query into a 5ms query at scale. Use explain() to identify slow queries that are doing collection scans.

Lean queries: By default, Mongoose returns full document objects with all the prototype methods attached. Adding .lean() to your queries returns plain JavaScript objects, which are significantly faster to create and consume far less memory. For read-heavy endpoints, this can cut response time by 30% or more.

4. Error Handling as a First-Class Concern

Most Express apps handle errors inconsistently. Some routes return { error: "Something went wrong" }, others return a 500 with an HTML page, and some just crash the process. A consistent global error handler solves all of this.

Create a single error handling middleware (the one with 4 arguments in Express) and route every thrown error through it. Define custom error classes like NotFoundError and ValidationError that carry an HTTP status code. Your global handler reads the status from the error class and always returns a consistent JSON structure. This makes your API predictable to consume and trivial to debug from logs.

"A frontend developer should never have to guess what shape an error response takes. A global error handler guarantees they always get the same JSON structure, regardless of where in the system the error originated."

The Long Game

The patterns above add a small amount of structure upfront but pay back enormous dividends when the codebase reaches 50+ routes, when you hire a new developer who needs to onboard quickly, or when a security audit happens. A clean, layered Node.js API is a pleasure to maintain. A tangled one becomes a legacy system nobody wants to touch within 18 months of launch.

Want to build something together?

I build mobile apps, web applications, and Chrome extensions. Fast delivery, clean code, full ownership.