151: Final Full-Stack Project
Outcomes
By the end of this study session, you can:
- turn requirements into an integrated React, Express, and MongoDB design;
- start and verify every dependency in a repeatable order;
- verify authenticated, owner-scoped CRUD with useful UI states;
- diagnose failures by locating the broken request-lifecycle boundary; and
- explain one request from user action to database and back.
Retrieval warm-up (5 minutes)
- What belongs in a Vite
VITE_variable, and what never belongs there? - Why does
findByIdAndDelete(req.params.id)fail the authorization requirement? - Why can
await fetch(...)complete even when the API returned500?
Expected ideas: the public API base URL is suitable, secrets are not; ownership must be part of the database filter; Fetch fulfills on HTTP errors, so inspect response.ok.
Define "complete" before coding
The final Task Manager has deliberately narrow requirements.
User stories
- A visitor can register and log in.
- An authenticated user can create, list, complete, rename, and delete only their own tasks.
- A user can log out, after which protected requests fail.
- Refreshing the browser preserves the authenticated session and database tasks until expiry/logout.
- The interface clearly distinguishes checking session, loading tasks, failure, no tasks, populated list, and pending mutation states.
Accessibility, security, and performance acceptance criteria
- Passwords are Argon2id hashes with unique salts, never plaintext.
- The browser uses an
HttpOnlysession cookie, not a bearer token in web storage. - Production cookies are
Secureand explicitlySameSite; state-changing requests have a documented CSRF defense. - CORS uses exact configured origins with credentials, never wildcard plus credentials.
- Every task query uses the authenticated owner. Invalid input gets
400, missing/not-owned gets non-revealing404, unauthenticated gets401, and unexpected failures get generic500. - Malformed JSON gets
400, oversized JSON gets413, disallowed CORS origins get a recognized403on requests including preflight, PATCH is strictly allowlisted/typed/nonempty, and every list query has a validated cap. - Login has tested account/IP throttling and expensive password hashing has a measured concurrency cap. Production acceptance requires shared-store/distributed behavior and overload tests; a tiny in-memory snippet is not sufficient evidence.
- Forms have labels; status/error feedback is announced; every operation is keyboard accessible.
Terms, architecture, and end-to-end mental model
An API contract defines request and response behavior. A trust boundary is where untrusted data enters another layer. A negative test proves forbidden behavior stays forbidden.
Browser React components -> api service -> Fetch + cookie ^ | | JSON + HTTP status v Express: CORS/origin -> JSON -> session -> route -> ownership query | v Mongoose -> MongoDB
MongoDB contains users, tasks, and the configured session collection. The client knows public user fields and task fields. It never knows password hashes, MongoDB credentials, the session secret, or the session ID value (because HttpOnly prevents JavaScript access).
Freeze the API contract while debugging:
| Method and path | Body | Success | Important failures |
|---|---|---|---|
POST /api/auth/register | { email, password } | 201 { data: User } | 400 |
POST /api/auth/login | { email, password } | 200 { data: User } | generic 401 |
GET /api/auth/me | none | 200 { data: User } | 401 |
POST /api/auth/logout | none | 204 | 403 origin |
GET /api/tasks | bounded query | 200 { data: Task[] } | 400, 401 |
POST /api/tasks | { title, priority? } | 201 { data: Task } + Location | 400, 401 |
PATCH /api/tasks/:id | title?, completed?, priority? | 200 { data: Task } | 400, 401, 404 |
DELETE /api/tasks/:id | none | 204 | 401, 404 |
GET /api/health | none | 200 { data: { status: "ok" } } | none |
GET /api/ready | none | 200 { data: { status: "ready" } } | 503 |
Suggested 60-minute build
- 0-5: answer retrieval and read acceptance criteria.
- 5-15: verify environment, database connection, health endpoint, CORS, and session cookie.
- 15-30: finish the server routes and ownership filters from 094�104.
- 30-43: connect React authentication and stable CRUD through one API service.
- 43-53: execute the checklist, especially two-user negative tests.
- 53-60: trace and document the lifecycle; record one improvement.
When a check fails, stop adding features. Make the failing state observable, fix the smallest boundary, and rerun the check.
Complete beginner integration
Health and readiness
Add these before the protected routes. Health is deliberately small: it does not expose the MongoDB URI, driver error, or connection details. Deployment probes can restart an unhealthy process and stop sending traffic when readiness fails.
app.get("/api/health", (_req, res) => {
res.json({ data: { status: "ok" } });
});
app.get("/api/ready", (_req, res) => {
const ready = mongoose.connection.readyState === 1;
res.status(ready ? 200 : 503).json({
data: { status: ready ? "ready" : "not-ready" },
});
});
Do not make /api/health depend on a database query: liveness asks whether the process can respond. Readiness must be checked after MongoDB connects and during deployment. Add a timeout and an operational alert in a real deployment.
API service
Extend 105's request helper so auth and tasks share status/error/credential behavior:
const API_URL = import.meta.env.VITE_API_URL;
async function request(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
credentials: "include",
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error?.message || `Request failed (${response.status})`);
error.status = response.status;
error.code = payload.error?.code;
throw error;
}
return payload.data;
}
const json = (method, body) => ({ method, body: JSON.stringify(body) });
export const api = {
me: () => request("/auth/me"),
register: (values) => request("/auth/register", json("POST", values)),
login: (values) => request("/auth/login", json("POST", values)),
logout: () => request("/auth/logout", { method: "POST" }),
listTasks: (signal) => request("/tasks", { signal }),
createTask: (input) => request("/tasks", json("POST", input)),
updateTask: (id, changes) => request(`/tasks/${id}`, json("PATCH", changes)),
deleteTask: (id) => request(`/tasks/${id}`, { method: "DELETE" }),
};
With VITE_API_URL=http://localhost:3000/api, each path resolves once. A common final-day bug is accidentally producing /api/api/tasks or omitting /api.
React application state
Use a small explicit state machine rather than one ambiguous boolean:
function App() {
const [user, setUser] = useState(null);
const [authStatus, setAuthStatus] = useState("checking");
const [tasks, setTasks] = useState([]);
const [taskStatus, setTaskStatus] = useState("idle");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
api.me().then((user) => {
if (active) { setUser(user); setAuthStatus("authenticated"); }
}).catch((err) => {
if (!active) return;
if (err.status === 401) setAuthStatus("anonymous");
else { setError(err.message); setAuthStatus("error"); }
});
return () => { active = false; };
}, []);
useEffect(() => {
if (!user) return;
const controller = new AbortController();
setTaskStatus("loading");
api.listTasks(controller.signal).then((tasks) => {
setTasks(tasks);
setTaskStatus("ready");
}).catch((err) => {
if (err.name !== "AbortError") {
setError(err.message);
setTaskStatus("error");
}
});
return () => controller.abort();
}, [user]);
async function submitLogin(values) {
setError("");
try {
const user = await api.login(values);
setUser(user);
setAuthStatus("authenticated");
} catch (err) { setError(err.message); }
}
async function submitRegister(values) {
setError("");
try {
const user = await api.register(values);
setUser(user);
setAuthStatus("authenticated");
} catch (err) { setError(err.message); }
}
async function logout() {
try {
await api.logout();
setUser(null);
setTasks([]);
setAuthStatus("anonymous");
} catch (err) { setError(err.message); }
}
if (authStatus === "checking") return <main><p aria-live="polite">Checking session...</p></main>;
if (authStatus === "error") return <main><p role="alert">{error}</p></main>;
if (!user) return <AuthForm onLogin={submitLogin} onRegister={submitRegister} error={error} />;
return <TaskScreen user={user} tasks={tasks} setTasks={setTasks}
status={taskStatus} error={error} setError={setError} onLogout={logout} />;
}
AuthForm uses controlled email/password inputs, type="email", type="password", labels, autocomplete="email" and autocomplete="current-password", a pending submit state, and <p role="alert">. TaskScreen implements 105's functional create/update/delete setters. Do not optimistically remove a task before this beginner version receives success; a failed request should leave server and UI consistent. Disable only the affected operation while pending.
Here is a compact core implementation for those two components. It includes registration, rename, completion, deletion, and per-row pending state without adding a form library, router, or query cache:
function AuthForm({ onLogin, onRegister, error }) {
const [mode, setMode] = useState("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [pending, setPending] = useState(false);
async function submit(event) {
event.preventDefault(); setPending(true);
try { await (mode === "login" ? onLogin : onRegister)({ email, password }); }
finally { setPending(false); }
}
return <form onSubmit={submit} aria-busy={pending}>
<h1>{mode === "login" ? "Log in" : "Register"}</h1>
<label htmlFor="auth-email">Email</label>
<input id="auth-email" type="email" autoComplete="email" required
value={email} onChange={(event) => setEmail(event.target.value)} />
<label htmlFor="auth-password">Password</label>
<input id="auth-password" type="password"
autoComplete={mode === "login" ? "current-password" : "new-password"}
minLength={15} maxLength={128} required value={password}
onChange={(event) => setPassword(event.target.value)} />
{error && <p role="alert">{error}</p>}
<button disabled={pending}>{pending ? "Working..." : mode === "login" ? "Log in" : "Register"}</button>
<button type="button" onClick={() => setMode(mode === "login" ? "register" : "login")} disabled={pending}>
{mode === "login" ? "Create an account" : "Use existing account"}
</button>
</form>;
}
function TaskScreen({ user, tasks, setTasks, status, error, onLogout, setError }) {
const [title, setTitle] = useState("");
const [editing, setEditing] = useState(null);
const [pendingIds, setPendingIds] = useState(new Set());
async function mutate(id, operation) {
setPendingIds((ids) => new Set(ids).add(id));
try { await operation(); }
finally { setPendingIds((ids) => { const next = new Set(ids); next.delete(id); return next; }); }
}
async function add(event) {
event.preventDefault(); const clean = title.trim(); if (!clean) return;
try { await mutate("new", async () => { const task = await api.createTask({ title: clean }); setTasks((items) => [task, ...items]); setTitle(""); }); }
catch (err) { setError(err.message); }
}
async function update(task, changes) {
try { await mutate(task.id, async () => { const updated = await api.updateTask(task.id, changes); setTasks((items) => items.map((item) => item.id === updated.id ? updated : item)); }); }
catch (err) { setError(err.message); }
}
async function remove(task) {
try { await mutate(task.id, async () => { await api.deleteTask(task.id); setTasks((items) => items.filter((item) => item.id !== task.id)); }); }
catch (err) { setError(err.message); }
}
return <main><p>Signed in as {user.email}</p><button onClick={onLogout}>Log out</button>
<form onSubmit={add}><label htmlFor="new-task">New task</label><input id="new-task" value={title} onChange={(e) => setTitle(e.target.value)} /><button disabled={!title.trim() || pendingIds.has("new")}>Add</button></form>
{error && <p role="alert">{error}</p>}
{status === "loading" && <p aria-live="polite">Loading tasks...</p>}
{status === "error" && <p role="alert">Could not load tasks. Try again.</p>}
{status === "ready" && !tasks.length && <p>No tasks yet. Add the first one.</p>}
{status === "ready" && <ul>{tasks.map((task) => <li key={task.id}>
{editing === task.id ? <form onSubmit={(e) => { e.preventDefault(); update(task, { title: e.currentTarget.elements.title.value }); setEditing(null); }}>
<label htmlFor={`edit-${task.id}`}>Task title</label><input id={`edit-${task.id}`} name="title" defaultValue={task.title} /><button disabled={pendingIds.has(task.id)}>Save</button><button type="button" onClick={() => setEditing(null)}>Cancel</button>
</form> : <><label><input type="checkbox" checked={task.completed} disabled={pendingIds.has(task.id)} onChange={() => update(task, { completed: !task.completed })} /> {task.title}</label><button onClick={() => setEditing(task.id)} disabled={pendingIds.has(task.id)}>Rename</button><button onClick={() => remove(task)} disabled={pendingIds.has(task.id)}>Delete</button></>}
</li>)}</ul>}
</main>;
}
App passes both authentication callbacks and setError to TaskScreen. The component catches task failures so its role="alert" message is set while the old task remains visible. The example intentionally waits for server success before changing task data.
On any protected call returning 401, a polished API boundary can transition to anonymous, clear user-specific state, and announce that the session expired. Do not treat a 403 CSRF/origin rejection as "not logged in"; show it as a request failure.
Setup and run checklist
Complete each box in order.
- Install the Node 24.19 LTS baseline, which includes the official Argon2 API.
- Start local MongoDB or create an Atlas database with least-privilege credentials and network rules.
- In
server, installexpress mongoose cors express-session connect-mongo dotenv. - Import the single 106 model module; remove 105's inline
Taskcompilation. - In
client, install existing dependencies and verify Vite scripts. - Add
.env*secrets to.gitignore; commit only.env.examplefiles. - Set server
MONGODB_URI, an unpredictableSESSION_SECRET,CLIENT_ORIGINS=http://localhost:5173, andPORT=3000. - Set client
VITE_API_URL=http://localhost:3000/api; confirm it contains no secret. - Run the server with its package script and wait for successful MongoDB connection before listening.
- Run the Vite client with
npm run dev; restart it after env changes. - Open the exact allowed URL, normally
http://localhost:5173, not a different hostname such as127.0.0.1. - Confirm no startup stack trace, browser console error, or failed health/database check.
-
GET /api/healthreturns200;GET /api/readyreturns200only after MongoDB is connected and503otherwise. - Run
npm run buildandnpm run lintin the client; run server tests/lint if configured.
In production, use HTTPS, Secure, a correctly configured proxy, a production session store, and the exact deployed origin. Never expose .env files or MongoDB.
Deployment and test evidence
Build the client once with the production API URL and serve its output from the chosen static host. Run the server with the deployment's secret manager, not a committed .env file. Configure the reverse proxy to serve the client and forward /api to Express, then set the same public origin in CLIENT_ORIGINS. Keep /api/health as the process probe and /api/ready as the traffic/readiness probe. Verify HTTPS, cookie attributes, proxy trust, MongoDB network rules, session-store persistence, and graceful shutdown before accepting users.
The minimum repeatable test run is:
# server terminal npm test # client terminal npm run lint npm run build # smoke checks from another terminal curl.exe -i http://localhost:3000/api/health curl.exe -i http://localhost:3000/api/ready
If a package has no test or lint script yet, add one or record NOT CONFIGURED; do not report an unexecuted command as a passing test. Integration tests should use a separate database, create two isolated cookie agents, clean their data, and assert both success and denied paths. Never run destructive test cleanup against a shared development or production database.
For CI/CD, promote the exact commit that passed client checks, server unit/integration tests, contract checks, and the small E2E suite. Deploy a preview first, run health/readiness plus disposable register/create/read/logout smoke checks, and retain reports on failure. Monitor request rate, 4xx/5xx rate, latency, readiness, session-store errors, and MongoDB pool/replica health. Roll back to the last known-good artifact if smoke checks fail, thresholds regress, or data correctness is uncertain. Re-run smoke tests after rollback and record request ID, release, evidence, mitigation, root cause, regression test, and prevention.
Test checklist
Browser behavior
- Initial page shows "Checking session," not a misleading login flash.
- Registration accepts a long passphrase and never displays/logs it.
- Refresh remains signed in; DevTools shows an
HttpOnly, explicitSameSitecookie. - Empty account shows an empty state.
- Create uses returned public
id; rename and complete survive refresh; delete survives refresh. - A failed API request produces
role="alert"feedback and does not silently change data. - Tab/Enter/Space operate every form, checkbox, and button; labels have useful accessible names.
- Logout returns to login; Back/refresh cannot load protected data.
API and security behavior
- Missing/invalid inputs return
400; malformed object IDs do not expose stack traces. - Malformed JSON returns coded
400; over-limit JSON returns coded413. - Empty, unknown-field, and wrong-type PATCH bodies return coded
400. - Invalid or excessive list limits return
400; normal lists never exceed the cap. - Wrong email and wrong password both return
401with "Invalid email or password." - No cookie on
/tasksreturns401. - Disallowed
Originon a state-changing request returns403. - Disallowed-origin
OPTIONSpreflight also returns recognized coded403. - Create account A and account B. B cannot list, update, or delete A's task, even with A's ID.
- Inspect a user document: it contains algorithm/version/parameters and salt/hash buffers, never plaintext.
- Account/IP login throttles and password-hash concurrency limits pass overload and recovery tests in the deployed topology.
- Logout destroys server state; replaying the old session cookie fails.
- CORS response names the allowed origin and permits credentials; it is not
*.
On Windows, use curl.exe. This smoke test keeps cookies in a temporary jar:
# Replace the marker with a disposable test password only. Never put a real # credential in a command-line argument: shell history and process inspection # may expose it. curl.exe -i -c cookies.txt -H "Origin: http://localhost:5173" -H "Content-Type: application/json" --data '{"email":"user@example.com","password":"REPLACE_WITH_DISPOSABLE_TEST_PASSWORD"}' http://localhost:3000/api/auth/register curl.exe -i -b cookies.txt -H "Origin: http://localhost:5173" http://localhost:3000/api/tasks curl.exe -i -b cookies.txt -H "Origin: http://localhost:5173" -H "Content-Type: application/json" --data '{"title":"Explain the lifecycle"}' http://localhost:3000/api/tasks
Delete cookies.txt after testing because it contains an active session identifier. Automated integration tests should use isolated test data and assert both allowed and denied behavior.
Mistakes and debugging by boundary
Trace UI event, final URL/method/body/credentials, preflight, middleware order, session, allowlisting, the server-side { _id, owner } query, database result, envelope, and React's functional update. Stop at the first boundary whose observed input differs from its contract.
The Network tab is the source of truth for browser HTTP. Correlate it with a sanitized server log containing method, path, status, duration, and a request ID. Never log passwords, session IDs, cookies, hashes, or full sensitive bodies.
Incident checklist: [ ] write symptom, scope, start time, and owner; [ ] compare a failing and known-good request; [ ] check release and configuration changes; [ ] identify the first boundary with mismatched input/output; [ ] mitigate before changing multiple layers; [ ] verify recovery with the user journey and regression test.
Complete request lifecycle review
Trace "Alice completes her task":
- Alice activates the checkbox; React calls
api.updateTask(task.id, { completed: true })and marks the row pending. - Fetch sends credentialed JSON. Cross-origin development may first issue an allowlisted
OPTIONSpreflight. - Origin/CSRF policy, JSON parsing, session loading, and
requireUserreject unsafe, malformed, or anonymous requests. - The route allowlists
completedand queries by storage_idplus Alice's owner ID. Bob's owner ID would not match, producing non-revealing404. - Mongoose validates and returns the updated document; Express serializes public
idin{ data: task }. - The service checks
ok, unwrapsdata, and React replaces matchingid. Failure retains old state and announces the error.
That story covers interaction, transport, browser policy, session authentication, authorization, validation, persistence, response semantics, and accessible feedback.
Intermediate and optional advanced
Intermediate: add automated API integration tests with two agents/cookie jars, an indexed { owner: 1, createdAt: -1 } query, pagination, per-row pending state, and an error boundary for render failures. Test authorization regression whenever routes change.
Optional advanced: design session-bound CSRF tokens, login rate limits, email verification, password recovery, passkeys/MFA, audit events, health probes, secure headers, backups, rotation, and observability. Measure before caching. Do not switch to JWT merely to sound advanced; document issuer, audience, algorithm allowlist, storage, expiry, replay, key rotation, and revocation if distributed architecture requires it.
Tiered exercises
- Foundation: draw the architecture and annotate which layer owns each acceptance criterion.
- Core: execute the checklists and record one failure with its boundary, evidence, fix, and retest.
- Stretch: write the two-user ownership test as executable pseudocode and propose one performance index.
Foundation: React owns rendering/pending/accessibility; API service owns request consistency; browser enforces CORS/cookies; Express owns validation, AuthN/AuthZ, errors, and CSRF policy; Mongoose/MongoDB own schema rules, indexed queries, and persistence; the session store links cookie ID to user ID.
Core example: "Create returned 403. Network showed Origin: http://127.0.0.1:5173, while CLIENT_ORIGINS allowed http://localhost:5173. I opened the configured URL (or explicitly added the trusted origin), repeated POST, received 201, then refreshed and confirmed persistence." Do not solve this with wildcard CORS.
Stretch pseudocode:
const alice = newAgent();
const bob = newAgent();
await alice.register("alice@example.com", strongPassword);
await bob.register("bob@example.com", anotherStrongPassword);
const task = (await alice.post("/tasks", { title: "Alice only" }).expect(201)).body.data;
await bob.patch(`/tasks/${task.id}`, { completed: true }).expect(404);
await bob.delete(`/tasks/${task.id}`).expect(404);
await bob.get("/tasks").expectBodyDataNotContaining(task.id);
await alice.get("/tasks").expectBodyDataContaining(task.id);
Create taskSchema.index({ owner: 1, createdAt: -1 }) because the stable list query filters by owner and sorts newest first. Confirm with query analysis at realistic scale rather than assuming every index helps.
Review and recap
Follow this order: register Alice, inspect the empty state, create/edit/complete tasks, refresh, inspect DevTools cookie attributes, log out and verify protected denial, log in as Bob and verify isolation, then trace the six-step lifecycle. End with one limitation and next control, such as rate limiting or CSRF tokens.
A finished full-stack feature is not "the page looks right." It has a clear contract, persistent server truth, explicit transient states, denied-path tests, object-level authorization, secure secret/session handling, accessible controls, repeatable setup, and an explainable request lifecycle.
Direct official references (checked 2026-08-24)
- React: Synchronizing with Effects
- Vite: Environment Variables and Modes
- Express 5: Error handling
- Express: CORS middleware
- Express: session middleware
- Mongoose: Models
- Mongoose: Validation
- MDN: Using Fetch
- MDN: CORS
- OWASP: Authorization
- OWASP: Password Storage
- OWASP: Session Management
- OWASP: CSRF Prevention
System-design case study: scale the Task Manager
Start with the current contract and identify pressure points. Stateless Express instances need a shared session store; Mongo needs an owner/time index, bounded cursor pagination, backups, and measured pool limits; React needs a query-cache or route-data strategy once screens share server state.
For 10x traffic, add a load balancer, connection-pool budgets, structured observability, and a shared rate limiter. For a slow bulk import, upload to object storage, enqueue an idempotent job, return 202 plus status, and poll or notify; Mongo remains authoritative. For a read-heavy dashboard, cache a projection with explicit invalidation/expiry, never cache authorization decisions across users.
Explain what you would measure before adding a queue, cache, replica, or index. Include duplicate job delivery, stale reads, session-store outage, primary failover, client retry, and partial UI mutation. Add a 202 contract test, two-user authorization test, E2E retry journey, and load test for login throttling and pagination.
