Chapter 1 — Node.js & Express
What is Node.js?
Node.js allows JavaScript to run on a server. Before Node.js, JavaScript only ran in browsers. Node.js gives JavaScript access to the file system, network, and memory — everything a server needs.
What is Express?
Express is a framework that wraps Node's built-in HTTP module and gives you clean tools for routing, middleware, and request/response handling.
Analogy: Node.js is the engine. Express is the steering wheel, pedals, and dashboard.
What is Middleware?
Middleware is a function that has access to the request, the response, and a next function. Every request travels through middleware before reaching its destination.
Request → middleware1 → middleware2 → controller → Response
Each middleware can:
- Read or modify the request
- Read or modify the response
- Call
next()to pass control forward - Stop the chain by sending a response
Critical rule: If a middleware does not call next() and does not send a response, the request hangs forever.
HTTP Status Codes
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful request |
| 201 | Created | Resource created |
| 400 | Bad Request | Invalid data from client |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource does not exist |
| 500 | Internal Server Error | Something broke on server |
Environment Variables
Never hardcode secrets. Store them in .env and read them with process.env:
import dotenv from 'dotenv';
dotenv.config(); // must run before anything reads process.env
Chapter 2 — Folder Architecture & The Controller Pattern
Separation of Concerns
Every file has exactly one job:
src/ ├── config/ → DB connection, env setup ├── controllers/ → Business logic ├── middleware/ → Gatekeeping and validation ├── models/ → Data shape and DB interface ├── routes/ → URL to controller mapping ├── utils/ → Reusable helpers └── server.js → Entry point
The Request Lifecycle
Incoming Request
│
▼
routes/ → Which controller handles this?
│
▼
middleware/ → Is the data valid? Is the user allowed?
│
▼
controllers/ → Run the business logic
│
▼
models/ → Talk to the database
│
▼
JSON Response → Send back the result
Why Controllers Instead of Logic in Routes?
- Logic in routes cannot be reused
- Logic in routes cannot be tested in isolation
- Controllers give each function one clear job
- When something breaks you know exactly which file to open
The Guard Clause Pattern
Always check for the failure condition and return early:
if (!user) return res.status(404).json({ message: "Not found" });
if (!isMatch) return res.status(401).json({ message: "Invalid" });
// only reach here if everything passed
Import Paths
./means start in my current folder../means go up one folder- Ask yourself: "Where am I and where is the file I need?"
Chapter 3 — MongoDB & Mongoose
What is MongoDB?
MongoDB stores data as JSON-like documents instead of rigid tables. This makes it a natural fit for JavaScript applications.
What is Mongoose?
Mongoose sits between your app and MongoDB giving you:
- Schemas — enforce the shape of your data
- Models — clean interface for querying
- Validation — reject malformed data
Schema and Model Pattern
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
}, { timestamps: true });
const User = mongoose.model('User', userSchema);
{ timestamps: true }
Automatically adds createdAt and updatedAt to every document. You never manage these manually.
DB Connection Rules
- Always
awaitthe connection before starting the server - Use
process.exit(1)on failure — do not let the app run without a database - Store
MONGO_URIin.env
Chapter 4 — Data Validation with Zod
Why Zod Over If Statements?
- 10 fields = 10+ if statements with no Zod
- Zod gives structured, consistent error messages automatically
- Schemas are reusable and live in one place
- Controllers can assume data is already clean
Zod Schema Pattern
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/(?=.*[A-Z])/, "Include one capital letter")
.regex(/(?=.*[a-z])/, "Include one lowercase letter")
.regex(/(?=.*[0-9])/, "Include one number")
.regex(/(?=.*[^A-Za-z0-9])/, "Include one symbol"),
});
safeParse vs parse
parsethrows an error on failuresafeParsereturns a result object you check — never throws- Always use
safeParsein middleware
Validation Middleware Pattern
const validateSchema = (schema) => {
return function(req, res, next) {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.issues.map(i => i.message)
});
}
req.body = result.data;
next();
};
};
Why Validate in Middleware Not Controller? Middleware runs before the controller. If validation fails the controller never runs. Each piece has one job — middleware validates, controller handles business logic.
Chapter 5 — Security: bcrypt & Password Hashing
Why Hash Passwords?
Credential stuffing — most users reuse passwords. If your database is breached and passwords are plain text, attackers own every other app that user has an account on.
Why bcrypt?
- Slow by design — limits brute force attacks to a few attempts per second instead of millions
- Adds a salt automatically — two users with the same password get completely different hashes
- One way — there is no reverse function to get the original password back
bcrypt Pattern
// Hashing — before saving to DB
const hashedPassword = await bcrypt.hash(plainPassword, 10);
// Comparing — during login
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
10 is the saltRounds — the industry standard default.
Why Check Email Exists Before Hashing? Hashing is computationally expensive. If the user already exists the request will fail anyway. Checking first avoids unnecessary computation and keeps your server fast.
Chapter 6 — JWT Authentication
What is JWT?
A JSON Web Token is a signed string with three parts:
header.payload.signature
- Header — token type and algorithm
- Payload — the data you store (userId, expiry)
- Signature — proves the token was not tampered with
Critical: The payload is base64 encoded not encrypted. Anyone can read it. Never put passwords or sensitive data in a JWT.
Why userId in the Payload?
_id is immutable — it never changes. Email and name are mutable — they can change. If you store mutable data in a token it becomes stale when the user updates their profile.
JWT Pattern
// Generate — after login
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Verify — in middleware
const decoded = jwt.verify(token, process.env.JWT_SECRET);
Why Same Error for Wrong Email and Wrong Password? Never tell an attacker which field was wrong. Knowing a valid email exists is enough information to launch targeted attacks. Always return the same generic message.
Expired vs Invalid Token
- Expired — the token was real but the session window is over
- Invalid — the token is malformed, tampered with, or signed with the wrong secret
Auth Middleware Pattern
const protect = (req, res, next) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ message: "No token" });
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid or expired token" });
}
};
Chapter 7 — OTP & Email Verification
Why Email Verification?
Without it users can register with emails they do not own. You have no proof of ownership. Password reset emails go to the wrong person.
Why crypto Over Math.random()?
Math.random() is predictable enough for sophisticated attackers to guess upcoming values. crypto.randomInt() is cryptographically secure — statistically impossible to predict.
OTP Pattern
const otp = crypto.randomInt(100000, 999999).toString();
const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
Why Delete OTP After Verification? If the OTP stays in the database, an attacker who gains database access later can replay it. Deleting it means it has a single use lifetime — once consumed it is gone.
Why Check Expiry on the Server? The client is never trusted. Any user can modify JavaScript in their browser or send requests directly via tools. Server side validation cannot be bypassed by the client.
Nodemailer Pattern
const sendEmail = async (to, subject, html) => {
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS, // Gmail App Password not real password
},
});
await transporter.sendMail({
from: process.env.EMAIL_USER,
to, subject, html,
});
};
Chapter 8 — Forgot Password
The Flow
User submits email
│
▼
Server generates crypto reset token
│
▼
Token stored in DB with 15 min expiry
│
▼
Reset link emailed to user
│
▼
User clicks link → submits new password
│
▼
Server verifies token → updates password → clears token
Why a Token Instead of OTP for Password Reset?
A token in a link is better UX — user just clicks instead of copying a code. Tokens are also longer and harder to guess than 6 digit numbers.
Reset Token Pattern
const resetToken = crypto.randomBytes(32).toString('hex'); // 64 char string
const resetTokenExpiry = new Date(Date.now() + 15 * 60 * 1000);
Why Single Use? Once used the token is deleted. Replaying the same reset link does nothing. This prevents attackers from reusing intercepted links.
Chapter 9 — React Frontend
React Context
Solves prop drilling — passing data through components that do not need it just to reach a deeply nested component.
Three Parts of Context
createContext()— creates the empty boxProvider— holds state and puts it in the boxuseContext()— any component reads from the box
Auth Context Pattern
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('token'));
const login = (newToken) => {
setToken(newToken);
localStorage.setItem('token', newToken);
};
const logout = () => {
setToken(null);
localStorage.removeItem('token');
};
return (
<AuthContext.Provider value={{ token, login, logout }}>
{children}
</AuthContext.Provider>
);
};