Module: Full Stack
Full Stack·151·12 MIN READ

151: Final Full-Stack Project

TOPICS COVERED: 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)

  1. What belongs in a Vite VITE_ variable, and what never belongs there?
  2. Why does findByIdAndDelete(req.params.id) fail the authorization requirement?
  3. Why can await fetch(...) complete even when the API returned 500?

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 HttpOnly session cookie, not a bearer token in web storage.
  • Production cookies are Secure and explicitly SameSite; 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-revealing 404, unauthenticated gets 401, and unexpected failures get generic 500.
  • Malformed JSON gets 400, oversized JSON gets 413, disallowed CORS origins get a recognized 403 on 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.

text
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 pathBodySuccessImportant 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/menone200 { data: User }401
POST /api/auth/logoutnone204403 origin
GET /api/tasksbounded query200 { data: Task[] }400, 401
POST /api/tasks{ title, priority? }201 { data: Task } + Location400, 401
PATCH /api/tasks/:idtitle?, completed?, priority?200 { data: Task }400, 401, 404
DELETE /api/tasks/:idnone204401, 404
GET /api/healthnone200 { data: { status: "ok" } }none
GET /api/readynone200 { 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.

js
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:

js
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:

jsx
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:

jsx
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, install express mongoose cors express-session connect-mongo dotenv.
  • Import the single 106 model module; remove 105's inline Task compilation.
  • In client, install existing dependencies and verify Vite scripts.
  • Add .env* secrets to .gitignore; commit only .env.example files.
  • Set server MONGODB_URI, an unpredictable SESSION_SECRET, CLIENT_ORIGINS=http://localhost:5173, and PORT=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 as 127.0.0.1.
  • Confirm no startup stack trace, browser console error, or failed health/database check.
  • GET /api/health returns 200; GET /api/ready returns 200 only after MongoDB is connected and 503 otherwise.
  • Run npm run build and npm run lint in 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:

powershell
# 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, explicit SameSite cookie.
  • 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 coded 413.
  • 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 401 with "Invalid email or password."
  • No cookie on /tasks returns 401.
  • Disallowed Origin on a state-changing request returns 403.
  • Disallowed-origin OPTIONS preflight also returns recognized coded 403.
  • 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:

powershell
# 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":

  1. Alice activates the checkbox; React calls api.updateTask(task.id, { completed: true }) and marks the row pending.
  2. Fetch sends credentialed JSON. Cross-origin development may first issue an allowlisted OPTIONS preflight.
  3. Origin/CSRF policy, JSON parsing, session loading, and requireUser reject unsafe, malformed, or anonymous requests.
  4. The route allowlists completed and queries by storage _id plus Alice's owner ID. Bob's owner ID would not match, producing non-revealing 404.
  5. Mongoose validates and returns the updated document; Express serializes public id in { data: task }.
  6. The service checks ok, unwraps data, and React replaces matching id. 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

  1. Foundation: draw the architecture and annotate which layer owns each acceptance criterion.
  2. Core: execute the checklists and record one failure with its boundary, evidence, fix, and retest.
  3. 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:

js
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)

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.