Terminal showing a Node.js server running
Back to Blog
Node.jsExpressMongoDBBackendREST API

Building a REST API with Node.js, Express, and MongoDB

A practical walkthrough of how I set up production-ready REST APIs — from project structure and middleware to error handling and authentication.

Published on December 10, 202410 min read

The Goal

This isn't a "hello world" tutorial. I want to walk through the decisions that actually matter when you're building an API that will run in production — one that handles auth, errors gracefully, and stays maintainable as it grows.

Project Structure

/src
  /routes        — Express route definitions
  /controllers   — Request handlers (thin, delegate to services)
  /services      — Business logic
  /models        — Mongoose schemas
  /middlewares   — Auth, error handling, validation
  /utils         — Shared helpers
  /config        — Environment config, DB connection
index.ts         — Entry point

The core principle: controllers are thin. A controller's only job is to parse the request, call the right service, and send the response. All business logic lives in services. This makes both sides independently testable.

Middleware Stack

Every Express app needs a consistent middleware stack. Here's mine:

app.use(helmet());                    // Security headers
app.use(cors({ origin: allowedOrigins }));
app.use(express.json({ limit: "10kb" }));  // Prevent payload attacks
app.use(morgan("combined"));          // Request logging
app.use("/api", rateLimiter);         // Rate limiting

Never skip `helmet()`. It sets a dozen security headers automatically — Content-Security-Policy, X-Frame-Options, and more. It's one line and costs nothing.

Error Handling

Centralised error handling is non-negotiable. I use a custom AppError class and a global error middleware:

class AppError extends Error {
  constructor(
    public message: string,
    public statusCode: number,
    public isOperational = true
  ) {
    super(message);
  }
}

// Global error middleware (last in the stack)
app.use((err: AppError, req: Request, res: Response, next: NextFunction) => {
  const status = err.statusCode || 500;
  const message = err.isOperational ? err.message : "Internal server error";
  res.status(status).json({ status: "error", message });
});

Operational errors (bad input, not found) send descriptive messages. Programming errors (unexpected crashes) send a generic message and get logged to a monitoring service.

Authentication with JWT

I avoid sessions for stateless APIs. JWT works well with the following pattern:

1. User logs in → server issues a short-lived access token (15 min) and a long-lived refresh token (7 days, stored in an httpOnly cookie).

2. Client sends the access token in the Authorization: Bearer header.

3. When the access token expires, the client hits /auth/refresh to get a new one using the cookie.

4. Logging out clears the httpOnly cookie server-side.

This pattern avoids storing tokens in localStorage (XSS risk) while still supporting stateless auth.

Mongoose Schema Best Practices

const productSchema = new Schema(
  {
    name: { type: String, required: true, trim: true, maxlength: 200 },
    price: { type: Number, required: true, min: 0 },
    isActive: { type: Boolean, default: true },
    createdBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
  },
  {
    timestamps: true,     // adds createdAt and updatedAt automatically
    versionKey: false,    // removes the __v field
  }
);

Always use timestamps: true. You'll thank yourself later when debugging production issues and trying to figure out when a record was last modified.

Validation

I use Zod for request validation. It integrates cleanly with TypeScript and gives you both runtime validation and static types from the same schema:

const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
});

// In middleware:
const result = createProductSchema.safeParse(req.body);
if (!result.success) {
  return next(new AppError(result.error.message, 400));
}

Final Thoughts

The patterns above — centralized errors, thin controllers, JWT with refresh tokens, Zod validation — add maybe a day of setup time to a new project. They save weeks of debugging and security patching later.