0 / 91
Week 10 · Day 64 of 91

Connect frontend to API

Connecting the Full Stack

Objective

Replace mock data with real HTTP requests and trace one request end to end.

This is system integration: individual modules may pass bench tests but the complete signal path must also be verified end to end.

  • base URLs and environment config
  • request lifecycle
  • JSON contracts

Why this matters

Your React app has been rendering data it made up. Your Express API has been answering requests nobody sent. Today you connect them, and learn the boundary well enough to say — with evidence, not a guess — which side is wrong when something breaks. Every module passed its bench test; now the whole signal path gets verified end to end.

Two programs, two origins

On Day 5 you learned that a program listening on a port is a process. You now run two at once:

http://localhost:5173   Vite dev server  → serves your React app to the browser
http://localhost:3000   Express          → serves /api/equipment, talks to PostgreSQL

The browser calls scheme + host + port an origin. These two differ in the port, so they are different origins — as far apart in the browser's eyes as example.com and attacker.net. That one fact causes most of today's friction.

Two boards, one bench cable

Picture a display board and a controller board, powered separately, interacting only through the cable between them, each with its own fault LED. Browser and API are the same: separate processes with separate logs, joined by one link. When the display shows nothing, the fault is on one board, in the cable, or in the protocol — and your job is to say which.

Base URLs and environment config

Every request needs a base URL: the part before the path, here http://localhost:3000. Typing that literal into twelve components is a trap — the value differs on your machine, a teammate's, and in production. Create .env.local in the frontend project root:

VITE_API_URL=http://localhost:3000

Two rules of real Vite behaviour: only variables prefixed VITE_ reach your code, as import.meta.env.VITE_API_URL; and the value is substituted into the built JavaScript, shipped to every visitor and readable by anyone — so never put a database password or an API secret in one.

Vite reads .env files only at dev-server startup, so after editing one you must restart npm run dev. Add .env.local to .gitignore — Vite's starter already ignores *.local. Put the base URL in exactly one module, src/lib/api.ts, and let the rest of the app forget it exists.

What the browser is actually enforcing

Point React at the API and the first thing you probably see is this, in the console:

Access to fetch at 'http://localhost:3000/api/equipment' from origin
'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

Read it carefully; it does not mean what most beginners think. The request was sent. The server answered. Your Express log shows the hit. What the browser blocked is your page's ability to read the response.

That is the same-origin policy from Day 53: JavaScript on one origin may not read responses from another unless that origin says so. CORS — Cross-Origin Resource Sharing — is how a server says so: an Access-Control-Allow-Origin header naming who may read.

So CORS protects users of other websites, not your server. If bank.example allowed any origin, a malicious page could read your balance using your logged-in browser. But CORS gives your API no protection against curl or anything that ignores the browser — which is why authorization must still be enforced on the server, every time.

A receptionist, not a lock

CORS is a receptionist telling a courier "you may hand this envelope over" or "you may not". The courier is the browser, and it obeys. Anyone arriving without a courier — curl, a script, an attacker — never speaks to the receptionist. The lock on the door is the server's auth check.

In Express, configure it explicitly:

import cors from 'cors';

app.use(cors({ origin: 'http://localhost:5173' }));

`origin: '*'` is a decision, not a default

The setting pasted on forums means "any website's JavaScript may read my responses". Fine for a public read-only API; a hole for one returning private data. It is also incompatible with credentialed requests — the browser rejects * when cookies are attached, as you meet on Day 66.

The other way out

You can also make the two origins into one. Vite's dev server can proxy /api to your Express port, so the browser sees a single origin and CORS never applies: server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } in vite.config.ts. It is a fine choice, but configure CORS at least once first — otherwise you will meet these headers for the first time in production, which is the worst place to meet them.

Preflight: the question before the question

For some requests the browser asks permission first — a preflight. It sends OPTIONS carrying Access-Control-Request-Method and Access-Control-Request-Headers, and sends the real request only if the server answers with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers.

Triggers: a method other than GET, HEAD, or POST; a header such as Authorization; or a Content-Type other than text/plain, multipart/form-data, or application/x-www-form-urlencoded. Since you send Content-Type: application/json, every POST and PATCH this week is preflighted — expect two Network rows per save. cors answers them for you.

The request lifecycle

The whole path, in the layer names you built in Week 8. Today's deliverable is this table, filled in with real values from your own app.

Hop Where What it holds
1 React component a user event, then apiFetch('/api/equipment')
2 Network GET http://localhost:3000/api/equipment
3 Express route matches the path, calls the service
4 Service applies rules, calls the repository
5 Repository runs SELECT ... FROM equipment ORDER BY name
6 Response rows → JSON → network → React state → screen

Four states, not one

On Day 1 you learned the network is slow and it fails. That is a design requirement. Every screen that loads data has four normal states: loading (in flight; say so), error (it failed or the server said no — say what happened, offer a retry), empty (zero rows, which is a success), and data. Beginners write the fourth and ship. Then a slow connection shows a blank page and an empty database looks identical to a crash. Write all four from now on.

JSON contracts and honest types

The API's response shape is a contract: field names, types, and which fields may be missing. Nothing enforces it automatically.

TypeScript cannot help by default, and this catches everyone: response.json() is typed Promise<any>. Writing const list: Equipment[] = await res.json() checks nothing — it asserts. Rename serial_number to serial on the server and TypeScript stays silent while the UI shows undefined. So validate once, at the seam, with the unknown narrowing from Day 32:

export type Equipment = { id: number; name: string; status: string };

export function isEquipment(v: unknown): v is Equipment {
  const e = v as Equipment;
  return typeof v === 'object' && v !== null &&
    typeof e.id === 'number' && typeof e.name === 'string';
}

One honest check at the boundary beats a hundred confident annotations inside it.

Walkthrough

Start PostgreSQL and your API, then confirm the API works alone before involving the browser:

curl -i http://localhost:3000/api/equipment
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

If that fails, stop — the problem is behind the API and no frontend change fixes it.

If the list already requires a session

Week 8 put equipment routes behind auth, so this curl may return 401. For today only, mount the list route before the auth middleware with the comment // TODO(day-66): restore requireAuth. Never ship an app with that comment still in it.

Now the one API module, src/lib/api.ts:

const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';

export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: { 'Content-Type': 'application/json', ...options.headers },
    ...options
  });
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status} ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}

Note what fetch does not do: it does not reject on 404 or 500. Only a network-level failure rejects the promise. Checking response.ok is what turns an HTTP error into a JavaScript error.

Now the component, with all four states, using the effect and cleanup pattern from Day 61:

export function EquipmentList() {
  const [items, setItems] = useState<Equipment[]>([]);
  const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');

  useEffect(() => {
    const controller = new AbortController();
    apiFetch<Equipment[]>('/api/equipment', { signal: controller.signal })
      .then((data) => { setItems(data); setStatus('ready'); })
      .catch((err) => { if (err.name !== 'AbortError') setStatus('error'); });
    return () => controller.abort();
  }, []);

  if (status === 'loading') return <p>Loading equipment…</p>;
  if (status === 'error') return <p role="alert">Could not load equipment.</p>;
  if (items.length === 0) return <p>No equipment yet.</p>;
  return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}

Sixty seconds, right now

DevTools → Network, tick Disable cache, filter Fetch/XHR, reload, click the equipment row: Headers shows method, URL, status; Response shows raw JSON. Stop Express, reload — the row turns red and your error state appears.

Pair mode — use this while tracing

"Help me trace this request through frontend function, HTTP route, service, repository, SQL, and response." Paste one file at a time; make it name the next hop before you show it. Pair mode means it proposes and you verify: inspect the diff, run checks, understand the behaviour.

Your turn

Load the equipment list from the real API and produce the traced request.

  1. Create .env.local with VITE_API_URL=http://localhost:3000. Restart npm run dev.
  2. Add src/lib/api.ts with apiFetch. Grep for localhost:3000 — no other file may contain it.
  3. Add cors to Express with your Vite origin named explicitly. Restart the API.
  4. Replace the mock array with the useEffect fetch. Delete the mock data; do not comment it out.
  5. Confirm all four states: normal load; stop the API and reload (error); empty the equipment table (empty); restore and reload (data).
  6. In DevTools → Network, record the request URL, method, status code, and response size.
  7. In your API terminal, find the log line for that request (Day 54 gave it a request ID) and the SQL your repository ran.
  8. Write docs/trace-equipment-list.md: one row per hop, each with a real value from your evidence.

You are done when

Your trace names the actual SQL statement and the actual HTTP status, and you can point at any hop and say what breaks if it is removed. Durable equipment data now comes from the API backed by the database — never again from hardcoded JSX or an array in a component.

Common pitfalls

  • Reading a CORS error as a server error. The server usually answered fine. Check the API log: if the request arrived and returned 200, the fault is the missing header, not the route.
  • Editing .env.local without restarting Vite. The old value stays baked in and you debug a change that never loaded.
  • Assuming res.ok is automatic. fetch resolves on 404 and 500. Without the check you call .json() on an error body and render nonsense.
  • A base URL with a trailing slash. It yields //api/equipment, which some servers 404 on.

Verify it yourself

Open today's reference, MDN's Fetching data from the server, and find where it handles responses.

  1. This lesson claimed fetch does not reject for a 404. Find the sentence that confirms or contradicts it, and note which property you must check instead.
  2. MDN shows a way of reading a response body this lesson did not use. Name it and say when it beats .json().

Add both answers to docs/trace-equipment-list.md. Confirming a claim from primary documentation is how you stop needing to trust lesson pages, including this one.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Load the equipment list from the Express API and inspect the request in DevTools and server logs.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A traced request from UI event to SQL result and back.

Working with AI today

AI as pair programmer

Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.

Help me trace this request through frontend function, HTTP route, service, repository, SQL, and response.

References

End-of-day quiz

Q1 Where should the frontend obtain durable equipment data?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.