Without notes, state yesterday’s main idea and one unresolved question.
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.
- Create
.env.localwithVITE_API_URL=http://localhost:3000. Restartnpm run dev. - Add
src/lib/api.tswithapiFetch. Grep forlocalhost:3000— no other file may contain it. - Add
corsto Express with your Vite origin named explicitly. Restart the API. - Replace the mock array with the
useEffectfetch. Delete the mock data; do not comment it out. - Confirm all four states: normal load; stop the API and reload (error); empty the
equipmenttable (empty); restore and reload (data). - In DevTools → Network, record the request URL, method, status code, and response size.
- In your API terminal, find the log line for that request (Day 54 gave it a request ID) and the SQL your repository ran.
- 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.localwithout restarting Vite. The old value stays baked in and you debug a change that never loaded. - Assuming
res.okis automatic.fetchresolves on404and500. 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.
- This lesson claimed
fetchdoes not reject for a404. Find the sentence that confirms or contradicts it, and note which property you must check instead. - 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
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Load the equipment list from the Express API and inspect the request in DevTools and server logs.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A traced request from UI event to SQL result and back.
Working with AI today
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
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.