Chapter 64: Node.js MongoDB
MongoDB in Node.js (2025–2026 reality).
We will build the knowledge together, step by step, as if I am sitting next to you right now:
- I open VS Code and the terminal
- I type every command and every line of code live
- I explain why we choose each decision
- I show what most beginners do wrong (and why it causes pain later)
- I show what intermediate developers often forget (leads to bugs, slow performance, security issues)
- I show what real production-grade code looks like in serious Node.js applications today
We will create a complete, realistic Task Management REST API using MongoDB + Mongoose + TypeScript + Express.
Step 1 – Project Initialization (modern & realistic)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
mkdir task-api-mongodb cd task-api-mongodb npm init -y npm pkg set type=module # Core runtime dependencies npm install express cors helmet compression dotenv zod jsonwebtoken bcryptjs mongoose # Development dependencies npm install -D \ typescript @types/node @types/express @types/cors @types/jsonwebtoken @types/bcryptjs \ tsx nodemon \ eslint prettier eslint-config-standard-with-typescript \ @typescript-eslint/parser @typescript-eslint/eslint-plugin \ lint-staged husky |
tsconfig.json (strict & modern)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } |
package.json scripts (modern & practical)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
"scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write .", "typecheck": "tsc --noEmit", "prepare": "husky install" } |
Step 2 – Folder structure (what most serious teams use)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
task-api-mongodb/ ├── src/ │ ├── config/ ← env + MongoDB connection │ │ ├── env.ts │ │ └── mongodb.ts │ ├── controllers/ ← HTTP handlers (thin) │ │ └── task.controller.ts │ ├── middleware/ ← auth, validation, error, rate-limit │ │ ├── auth.middleware.ts │ │ ├── error.middleware.ts │ │ └── validate.middleware.ts │ ├── models/ ← Mongoose schemas & models │ │ └── task.model.ts │ ├── routes/ ← route definitions │ │ └── task.routes.ts │ ├── schemas/ ← Zod schemas / DTOs │ │ └── task.schema.ts │ ├── services/ ← business logic │ │ └── task.service.ts │ ├── types/ ← shared types │ │ └── index.ts │ └── index.ts ← entry point ├── .env ├── .env.example ├── tsconfig.json └── package.json |
Why this structure?
- Controllers = HTTP layer only (thin)
- Services = business logic (testable, reusable)
- Models = Mongoose schemas (data shape & queries)
- Schemas = Zod runtime validation (incoming requests)
- Very easy to grow → add auth, users, notifications later
Step 3 – MongoDB connection (modern & safe)
src/config/mongodb.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import mongoose from 'mongoose' import env from './env.js' const MONGODB_URI = env.MONGODB_URI || 'mongodb://localhost:27017/taskdb' export async function connectMongoDB() { try { await mongoose.connect(MONGODB_URI, { // Modern options (2025–2026) maxPoolSize: 10, minPoolSize: 2, serverSelectionTimeoutMS: 5000, socketTimeoutMS: 45000, family: 4, // Prefer IPv4 (usually faster) }) console.log('MongoDB connected successfully') console.log(`Database: ${mongoose.connection.db.databaseName}`) } catch (err) { console.error('MongoDB connection failed:', err) process.exit(1) // fail fast in development } } // Connect on startup connectMongoDB() // Optional: graceful shutdown process.on('SIGTERM', async () => { console.log('SIGTERM received – closing MongoDB connection') await mongoose.connection.close() process.exit(0) }) |
src/config/env.ts (Zod validation – safety first)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { z } from 'zod' import 'dotenv/config' const envSchema = z.object({ PORT: z.coerce.number().default(5000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), MONGODB_URI: z.string().url().startsWith('mongodb'), JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters') }) export const env = envSchema.parse(process.env) |
Why this connection style?
- Mongoose manages connection pool automatically
- maxPoolSize / minPoolSize → controls resource usage
- serverSelectionTimeoutMS → fails fast when DB is down
- Graceful shutdown → closes connection on SIGTERM (Docker, PM2, Kubernetes)
- process.exit(1) on startup failure → fail-fast in dev
Step 4 – Mongoose model (Task)
src/models/task.model.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import mongoose from 'mongoose' const taskSchema = new mongoose.Schema({ title: { type: String, required: [true, 'Title is required'], trim: true, maxlength: [150, 'Title cannot be longer than 150 characters'] }, description: { type: String, trim: true, maxlength: [500, 'Description cannot be longer than 500 characters'] }, priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' }, completed: { type: Boolean, default: false }, dueDate: { type: Date, default: null }, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true // very important for performance }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now } }) // Auto-update updatedAt on save taskSchema.pre('save', function (next) { this.updatedAt = new Date() next() }) // Create & export model export const Task = mongoose.model('Task', taskSchema) |
Why this schema style?
- required, maxlength, enum → built-in validation
- trim: true → removes unwanted spaces
- index: true on user → fast queries when finding tasks per user
- pre(‘save’) hook → auto-maintains updatedAt
- ref: ‘User’ → enables population later
Step 5 – Create a task (INSERT example)
src/controllers/task.controller.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
import { Request, Response } from 'express' import { z } from 'zod' import { Task } from '../models/task.model.js' import { AppError } from '../middleware/error.middleware.js' const createTaskSchema = z.object({ title: z.string().min(1).max(150), description: z.string().max(500).optional(), priority: z.enum(['low', 'medium', 'high']).default('medium'), dueDate: z.string().datetime().optional().nullable(), completed: z.boolean().default(false) }) export const createTask = async (req: Request, res: Response) => { try { // 1. Validate input const input = createTaskSchema.parse(req.body) // 2. Get authenticated user (from middleware) const userId = req.user!.userId // assume auth middleware sets req.user // 3. Create task const task = await Task.create({ ...input, user: userId, dueDate: input.dueDate ? new Date(input.dueDate) : null }) res.status(201).json({ success: true, data: task }) } catch (err) { if (err instanceof z.ZodError) { throw new AppError(400, 'Validation failed') } throw err } } |
Route example
|
0 1 2 3 4 5 6 |
router.post('/', authenticate, createTask) |
Test it (after login → get token → send with Authorization: Bearer token)
|
0 1 2 3 4 5 6 7 8 9 |
curl -X POST http://localhost:5000/api/tasks \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-jwt-token" \ -d '{"title":"Finish quarterly report","priority":"high","dueDate":"2025-03-05T18:00:00Z"}' |
Step 6 – Summary – MongoDB + Mongoose INSERT best practices (2025–2026)
| Best Practice | Why it matters | Code pattern example |
|---|---|---|
| Use Mongoose schema validation | Catches bad data early | required: true, maxlength: 150, enum: […] |
| Always hash passwords | Never store plain text | bcrypt.hash(password, 12) |
| Use ObjectId for relations | Native MongoDB reference type | type: mongoose.Schema.Types.ObjectId |
| Use pre(‘save’) hooks | Auto-update fields like updatedAt | this.updatedAt = new Date() |
| Validate input with Zod first | Runtime safety + better error messages | createTaskSchema.parse(req.body) |
| Use async/await & try/catch | Clean error handling | await Task.create(…) |
| Return created document | Client gets full record immediately | res.status(201).json(task) |
| Use indexes on frequent query fields | 10×–100× faster queries | index: true on user field |
Which direction would you like to go much deeper into next?
- Login + JWT authentication with MongoDB
- Full task CRUD (create/read/update/delete + ownership check)
- Add pagination, filtering, sorting, search
- Add refresh tokens + cookie-based auth
- Add unit & integration tests with Vitest
- Docker + production deployment checklist
Just tell me what you want to build or understand next — I’ll continue with complete, secure, production-ready code and explanations. 😊
