Module 3 · ~12 min
🧱 Build static-first
You'll leave knowing how far one HTML file really goes — and how to borrow any public API without a backend.
Default posture: one HTML file
Almost every app in the fleet started as a single index.html. Static is free to host, impossible to crash, trivial to deploy (next module), and it ships today. Your default is static until something concrete forces otherwise.
What a single static file can genuinely do:
- Interactive maps — Leaflet or MapLibre, thousands of points, layers, popups.
- Charts — a charting library, or hand-rolled SVG for the simple ones.
- Search & filter — load a JSON dataset once, filter it entirely client-side.
- Local persistence —
localStorageholds user state: settings, streaks, even a whole spaced-repetition deck.
Real fleet examples. habooby = Leaflet + a wind/air-quality layer. acento = a full spaced-repetition trainer that lives in the browser with localStorage. who owns phoenix / ticket heat = MapLibre over public parcel and citation data. Every one a static single-page app — no backend for the core experience.
Data: bake it or fetch it
Two ways to get data into a static app:
- Bake it — fetch and clean the dataset once, write it to a
data.jsonyou ship next toindex.html. No live backend, instant loads, works offline. Best when the data changes daily or slower. - Fetch it live in the browser — when the data is genuinely real-time (weather, prices).
💡 Baking is the lazier default: a build step you run on your machine turns messy source data into one clean JSON. Your app never depends on the source API being up.
The CORS wall — and how to walk through it
The one thing that stops a static page from fetching a public API is CORS: the data is public, but the server doesn't send an Access-Control-Allow-Origin header, so the browser blocks your fetch. You do not need a backend to fix this. You proxy the call through your own reverse proxy, which adds the header server-side.
You'll set that reverse proxy up in Module 4 — here's the shape. Expose a path on your own domain that forwards to the upstream and injects CORS:
yourapp.yourdomain.com {
# /ext/* -> a public, keyless upstream that blocks CORS
handle /ext/* {
uri strip_prefix /ext
reverse_proxy https://public-api.example.gov {
header_up Host public-api.example.gov
header_up -Origin
header_down Access-Control-Allow-Origin "*"
}
}
handle {
reverse_proxy yourapp:80
}
}
Now your static page calls /ext/whatever — same origin, no CORS — and Caddy relays it upstream and adds the header. Keyless, backend-free, a few lines. This is exactly how the fleet borrows CORS-blocked government feeds (a pollen API, a county rain-gauge feed, the Census geocoder).
⚠️ Only proxy public, keyless endpoints this way. The moment a secret key is involved, that key lives server-side (Module 5) — never in the static page, never in a public proxy.
When you actually DO need a backend
Resist until one of these is true:
- Secrets that can't touch the client — a paid API key, the Stripe secret key.
- Accounts / payments — anything a user could forge if it lived in the browser.
- Shared or trusted state — data one user writes that others must see, or that must be tamper-proof.
Everything else stays static. A backend is a decision you justify — not a default you reach for.
Your turn ✅
- Stand up an
index.htmlthat loads your dataset (baked JSON or a live fetch). - Render the one view that makes the data legible — map, table, or chart.
- Hit a CORS wall? Write down the endpoint; you'll proxy it in Module 4.
- Keep any user state in
localStorage. Do not reach for a database yet.