Each stage leans on the one before it — auth doesn't make sense until you have a real API, and deployment doesn't make sense until you have something worth deploying.
STAGE 00
Node.js fundamentals & the event loop
Week 1Most tutorials treat Node as “JavaScript but for servers” without explaining why it can handle thousands of concurrent connections on a single thread — that understanding is what separates someone who can debug a hanging server from someone who can't.
What you'll learn
- The event loop, applied server-side this time — non-blocking I/O specifically
- CommonJS vs. ES modules in Node, and npm/package.json fundamentals
- What Express is actually abstracting away, by seeing what's underneath it first
Code
// Blocking — the whole process waits here
const data = fs.readFileSync("large-file.txt");
console.log("This waits for the read to finish");
// Non-blocking — Node moves on immediately
fs.readFile("large-file.txt", (err, data) => {
console.log("This runs when the read finishes, later");
});
console.log("This logs FIRST, before the file is read");BuildA plain HTTP server using Node's built-in http module — no Express yet, so you see exactly what Express saves you from writing by hand.
Self-checkWhy doesn't a single-threaded Node server fall over under 1,000 concurrent requests, when a single thread obviously can't run 1,000 things at once?
STAGE 01
Express — building a real REST API
Week 1–2Express tutorials usually show routes in isolation. Real APIs need middleware, error handling, and route organization from day one, or they become unmaintainable by the tenth route.
What you'll learn
- Routing and middleware — what middleware actually is: a function with next()
- Centralized error-handling middleware instead of try/catch in every route
- Organizing routes into separate files as the API grows
Code
app.use(express.json());
// Middleware: runs before the route handler, calls next() to continue
function logRequest(req, res, next) {
console.log(`${req.method} ${req.path}`);
next(); // forgetting this hangs the request forever
}
app.get("/api/todos", logRequest, (req, res) => {
res.json(todos);
});
// Error-handling middleware — 4 arguments is what makes Express treat it specially
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});BuildA full REST API for a “todos” resource — GET/POST/PUT/DELETE, proper status codes, organized into its own router file.
Self-checkWhat does calling next() actually do, and what happens if middleware forgets to call it?
STAGE 02
MongoDB & Mongoose — modeling data that isn't a spreadsheet
Week 2–3Coming from SQL (or nothing), the instinct is to model MongoDB like relational tables. The actual skill is knowing when to embed vs. reference data — that decision reshapes your whole schema.
What you'll learn
- Documents and collections, and how they differ from rows and tables
- Schema design: embedding vs. referencing, and the tradeoffs of each
- Mongoose schemas, validation, and basic queries
Code
const todoSchema = new mongoose.Schema({
title: { type: String, required: true },
done: { type: Boolean, default: false },
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, // reference
createdAt: { type: Date, default: Date.now },
});
const Todo = mongoose.model("Todo", todoSchema);
// Basic query
const userTodos = await Todo.find({ userId: currentUserId }).sort({ createdAt: -1 });BuildConnect the Stage 1 todos API to a real MongoDB database via Mongoose — replace the in-memory array with real persistence.
Self-checkYou're modeling blog posts and comments. When would you embed comments inside the post document, and when would you reference them separately?
STAGE 03
Authentication — sessions, JWTs, and why passwords are hard
Week 3–4“Just use bcrypt and JWT” is advice people follow without understanding what problem either one actually solves — which is exactly how security bugs happen.
What you'll learn
- Password hashing — why plaintext or reversible encryption is always wrong
- JWT structure and what “stateless” authentication actually means
- Session vs. token tradeoffs, and protecting routes with middleware
Code
// Signup: hash before storing, never store plaintext
const hash = await bcrypt.hash(password, 10);
await User.create({ email, passwordHash: hash });
// Login: issue a JWT after verifying
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(401).json({ error: "Invalid credentials" });
const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: "7d" });
// Protect a route
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
try {
req.userId = jwt.verify(token, process.env.JWT_SECRET).sub;
next();
} catch {
res.status(401).json({ error: "Unauthorized" });
}
}BuildAdd real signup/login to the todos API — hashed passwords, a JWT issued on login, and routes protected by auth middleware.
Self-checkWhy can't you “un-hash” a bcrypt password to recover the original, but you CAN decode a JWT's payload without any secret?
STAGE 04
React fundamentals — components, props, state
Week 4–5You already understand closures and the DOM from the JavaScript track, so React's mental model — components as functions, state as “what triggers a re-render” — clicks fast. This stage moves quicker than a from-zero React course.
What you'll learn
- Components as functions, and why props only flow one direction
- useState, and what “triggers a re-render” actually means
- Controlled inputs — the form pattern React expects
Code
function TodoInput({ onAdd }) {
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (!text.trim()) return;
onAdd(text);
setText("");
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}BuildA todo list UI — add, toggle, delete — using local state only, no API connection yet.
Self-checkWhy doesn't calling setState update the variable immediately within the same function call?
STAGE 05
React + API — data fetching, loading, errors
Week 5–6Tutorials show the happy-path fetch() call. Real apps spend more code on loading and error states than on the actual data display — treating those as an afterthought is why demos feel broken in production.
What you'll learn
- useEffect for data fetching, and the cleanup function that prevents a real race-condition bug
- Loading, error, and empty states as first-class UI, not edge cases
- Why fetching on every render would be a bug, and what dependency arrays actually control
Code
function TodoList() {
const [todos, setTodos] = useState([]);
const [status, setStatus] = useState("loading");
useEffect(() => {
let cancelled = false;
fetch("/api/todos")
.then((r) => r.json())
.then((data) => { if (!cancelled) { setTodos(data); setStatus("done"); } })
.catch(() => { if (!cancelled) setStatus("error"); });
return () => { cancelled = true; }; // prevents setState after unmount
}, []);
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p>Something went wrong.</p>;
return <ul>{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}BuildConnect the Stage 4 todo UI to the real backend API from Stages 1–3 — real loading and error states, not just the happy path.
Self-checkWhy does a useEffect data-fetch need a cleanup function to avoid a real bug, and what bug specifically?
STAGE 06
Connecting the stack — full CRUD end to end
Week 6–7This is the stage where “I know React” and “I know Express” become “I can build a product” — integration is its own skill, not just the sum of the parts.
What you'll learn
- Environment variables for API URLs across environments
- CORS — what it's actually protecting against, and configuring it correctly instead of allow-all
- Optimistic UI updates, and rolling back cleanly when a request fails
Code
// Correct CORS — allow only your real frontend origin
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
// Optimistic update — update UI immediately, roll back on failure
async function toggleTodo(id) {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
const res = await fetch(`/api/todos/${id}`, { method: "PATCH" });
if (!res.ok) {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t)); // revert
}
}BuildThe full CRUD todo app, working end to end locally — React frontend, Express/MongoDB backend, JWT auth, all connected.
Self-checkWhat is CORS actually protecting against, and why does setting Access-Control-Allow-Origin: * defeat that protection for a login-protected API?
STAGE 07
Deployment — getting it actually live
Week 7A MERN app that only runs on localhost isn't a portfolio piece. Deployment has its own gotchas — env vars, build steps, production CORS — that “it works on my machine” never surfaces.
What you'll learn
- Deploying the Express API to a free host (Render/Railway)
- Deploying the React frontend (Vercel/Netlify) and wiring the production API URL
- Connecting a hosted MongoDB (Atlas free tier) instead of a local database
Code
// .env (never committed) vs. .env.example (committed, documents what's needed)
// .env.example
MONGODB_URI=
JWT_SECRET=
FRONTEND_URL=
// Read once, fail loudly if missing — don't silently fall back in production
if (!process.env.MONGODB_URI) {
throw new Error("Missing MONGODB_URI");
}BuildDeploy the full Stage 6 app — a real live URL your frontend and backend both actually run at, not localhost.
Self-checkYour app works locally but the deployed frontend can't reach the deployed backend. What are the most likely causes, in order of likelihood?
STAGE 08
Production concerns — security, rate limiting, validation
Week 8The gap between “a working demo” and “something you'd trust with real user data” is a specific, learnable checklist — not a vague feeling of needing more polish.
What you'll learn
- Rate limiting — why it matters, and a basic implementation
- Common HTTP security headers (helmet.js) and what each actually prevents
- Server-side input validation — why client-side validation alone is not security
Code
// Basic rate limiting — cap requests per IP per window
const attempts = new Map();
function rateLimit(req, res, next) {
const ip = req.ip;
const count = (attempts.get(ip) || 0) + 1;
attempts.set(ip, count);
setTimeout(() => attempts.set(ip, Math.max(0, (attempts.get(ip) || 1) - 1)), 60_000);
if (count > 20) return res.status(429).json({ error: "Too many requests" });
next();
}
// Server-side validation — never trust the client alone
if (!title || title.trim().length === 0 || title.length > 200) {
return res.status(400).json({ error: "Invalid title" });
}BuildHarden the deployed app — add rate limiting, real server-side validation (not just frontend), and security headers via helmet.
Self-checkWhy is client-side form validation not actually a security measure, even though it's still worth having?