🧱 Build static-first
One HTML file and a data file go shockingly far: content as data, saved progress, deep links, search, no build step, and media rendered once.
You'll leave with
- Your app working as one index.html with its data kept in one place
- Saved progress, deep links and instant search with no server at all
- A rule for the exact moment you need a backend
Most of my fleet (the live apps at traqqit.com) started as one HTML file (plus a file or two) that a browser downloads and runs. This lesson builds your first version that way, with saved progress, deep links (an address that opens one particular view) and search, and gives you the rule for the day you need a backend: a program of yours on a server, the always-on computer that answers visitors.
One file goes shockingly far
One index.html can hold the whole app: page, styles, code and content. No framework, no build step, nothing to install, and no API keys to leak, because none of your code runs on a server.
What are HTML, CSS and JavaScript?
HTML is a page's content, CSS how it looks, JavaScript the code that makes it do things. One .html file holds all three: CSS in a <style> block, JavaScript in a <script> block.
What are a framework, a build step and an API key?
A framework (React, Vue) is a big library that organizes your code its own way; a build step compiles your code before a browser can run it. Both need installing and updating. An API key is a secret password from a paid service.
Acento, my Argentine Spanish trainer, launched exactly like this; its page is about 440 KB today. Audio, accounts, a leaderboard and an accent scorer came later, one at a time, and every update was still "copy the file up". First Bites, my baby-led weaning tracker, shipped its first working version in a day as one file (bottom tabs, a round + button, slide-up sheets) that later became its installable app and Android build with no rewrite (Make it an app on their phone, Ship to Google Play). Baby Signs is one HTML file plus a catalog file and a logic file.
🦥 The backend rule: add one only when a feature needs a secret key, data shared between people (or one person's devices), or a limit people can't cheat, because whoever holds the browser can read and change everything in a page. Acento's leaderboard and daily free limit run on a backend for exactly this; First Bites uses one only for optional sync. Add a backend only when forced builds it.
🛑 Never paste a secret key into a page, not even "for now": Ctrl+U (Mac: Cmd+Option+U) shows anyone every line.
Make the one file and open it on localhost
Use your idea from Find an idea the data can carry, or practice on a flashcard deck. In VS Code, your code editor, open your app's folder (File → Open Folder; the myapp folder from Set up your workshop is fine), open View → Terminal, click the + at the panel's top right for a second tab, and start a local web server there:
What are a local web server, localhost and npx?
A local web server hands a folder's files to your browser like a real server. http://localhost:3000 means "this computer, door 3000" (a door is a port); only you can open it. npx runs the serve tool without installing it for good.
npx serve
Type y if asked to install serve, open the Local address, and leave the tab running (Ctrl+C stops it; if port 3000 is taken, use the address it prints). In the first tab, type claude to start Claude Code, the AI assistant from setup, and ask, replacing the bold part:
EXAMPLE — YOU ASK CLAUDE CODE
What's JSON?
Data written as plain text: lists in [ ], labeled values in { }, like {"id": "money"}. People and programs can both read it.
What's happening underneath: one <style> block, your markup, then one <script> at the bottom, so the elements exist before the code runs. serve hands over the file as it is on disk, so refresh (F5; Mac: Cmd+R) after each change.
What you should see: your app at http://localhost:3000. A blank page means the script crashed: press F12 (Mac: Cmd+Option+I) for DevTools, the browser's developer tools, click Console, and paste the red error to Claude.
⚠️ Don't double-click index.html: as a file:// address, loading data.json fails and /engine.js points at the top of your disk, so you get a blank page. First Bites taught me this one.
Content as data, and media you render once
Keep every piece of content in one place, and make everything else read from it. In Acento, that's a JSON block inside index.html, between two markers; here's the shape in miniature:
<script>
/*DATA_START*/ const DATA = {"cats": [
{"id": "money", "title": "Money", "items": [
{"id": "la-plata-1", "text": "la plata", "note": "money, the everyday word"}
]}
]}; /*DATA_END*/
// hand-written code goes below the markers
const ITEMS = DATA.cats.flatMap(c => c.items);
</script>
The markers are comments, which the browser ignores; scripts use them to find the block, so Acento's page and its audio generator read the same text and can't drift apart. Keep hand-written code outside them, because the build script below rewrites only what's between. Content you write, up to a few hundred KB, lives here; a big download gets its own data.json (step 3); images and audio stay separate files.
Put your content in one place
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: strict JSON (double quotes, no comma after the last item) is all that JSON.parse, the reader scripts use, accepts. An id is a slug (text lowercased, accents removed, hyphens for spaces) plus a number. Saved progress points at ids, so with "item 7" ids, inserting one card would hand everyone's progress to the wrong card.
When content outgrows hand-editing
Acento grew from 82 cards to 905 in small batches, and nobody hand-edited its page. Each batch is a small file in a content folder:
[
{"deck": "money", "text": "la guita", "note": "money, street slang"},
{"deck": "money", "text": "estar en la lona", "note": "to be broke"}
]
A build script merges them. Node (JavaScript outside the browser, installed in setup) runs it, with no add-on packages:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: merging by deck name grows a deck instead of starting a second "money". Edit batches, never the block: a stray comma crashes the next build, and builds overwrite hand edits. Check the printed counts after every build.
Only if your app speaks: render the audio once
Browser voices differ by device and often have the wrong accent; Acento's came out Mexican-Spanish, which I called awful. edge-tts renders Microsoft Edge's online voices, Argentine Spanish included, to MP3 files with no API key: render once, serve the files like images, and use the browser's voice only when a file fails.
edge-tts is a Python tool (Python is another programming language; pip is its installer). Install Python 3 from python.org with the defaults (if its Windows installer offers Add python.exe to PATH, tick it), reopen VS Code, and make one clip with your own text and file name. -8% slows it for learners, and success is an MP3 in your folder that plays:
python -m pip install edge-tts
python -m edge_tts --list-voices # a long list: look for your language, like es-AR
python -m edge_tts --voice es-AR-ElenaNeural --rate=-8% --text "¿Qué onda?" --write-media que-onda-1.mp3
EXAMPLE — YOU ASK CLAUDE CODE
Check it yourself: run python gen_audio.py twice (Mac: python3). The second run should print +0 made, and the page should play a clip.
📍 Edge's free service started refusing Acento's requests (NoAudioReceived) after about 100 rapid calls. The generator is idempotent (a rerun only does what's unfinished, and zero-byte files count as unfinished), so the fix was to run it again, across more than 1,000 clips per voice.
⚠️ edge-tts uses an unofficial free service with no contract: keep the files, and check its terms before a paid product relies on it.
A data app with no backend at all
Downloaded a file? Turn it into the one file your page loads, ahead of time, so your app never depends on the source site being up. (Writing your own content? Skip step 3.)
Turn your download into data.json
Make a raw folder (in VS Code's file list: right-click an empty spot → New Folder) and drag your download, usually a CSV file, into it. In the prompt below, replace the bold part with its name.
What's a CSV?
A spreadsheet saved as plain text: one row per line, commas between columns. Values containing a comma, like "Phoenix, AZ", are wrapped in quotes, so splitting at every comma breaks rows.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: Node turns the CSV into one JSON file that the page downloads with fetch('data.json'). Re-run node build.mjs whenever the download changes: that's your pipeline (the heavy-duty version is Big public data on a small server).
Check it yourself: compare "rows read" with your spreadsheet's row count minus the header, open http://localhost:3000/data.json to see your data as text, then reload your app: it should show your rows.
Big is fine. The List, my New York City tax-roll explorer, ships a 21.9 MB JSON file that travels as about 1.3 MB, because the front door (the program on your server that receives every visitor) compresses it and the browser unpacks it: one line, encode zstd gzip, in Deploy behind one front door. serve does it locally.
Dropping empty fields saves more, so read optional fields with a fallback, as The List does: row.notes ?? [] means "the notes, or an empty list". Otherwise a record with nothing to say crashes the page.
⚠️ When the data changes, deploy data.json too, or the live page shows last month's numbers.
Save progress without a server
For an app without logins, the browser's localStorage is your database.
What's localStorage?
A small store in each browser, a few megabytes per site, that keeps text under a name (a key) through refreshes and reboots. It lives in one browser on one device: clearing site data erases it, private windows start empty, and Safari may delete it after about a week without a visit.
- Version the key: data under
myapp.v1, device preferences like the theme under their own key. When Baby Signs added household sync, only the data key had to sync, so one phone's theme can't flip another's. - Load through
migrate(), a function that upgrades old saves to today's shape. When First Bites added twins,migrate()turned the old single baby into a list and stamped every past feeding with that baby's id: nobody lost their history. - Wrap storage in try/catch, which catches errors instead of crashing; private windows can refuse it.
const KEY = 'myapp.v1'; // your data: events, profile
const PREFS = 'myapp.prefs'; // this device only: the theme
function load() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {}
return migrate(saved);
}
function save(state) {
try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
}
function migrate(s) { // every load: old or broken saves come out valid
if (!s || typeof s !== 'object' || Array.isArray(s)) s = {};
if (!Array.isArray(s.events)) s.events = [];
s.events = s.events.filter(e => e && e.id && typeof e.ts === 'number');
return s;
}
For a change too big for migrate(), write myapp.v2 and keep v1 until the new key has proved itself.
Store events, not statuses
Save what happened as a list of events, and compute every status, streak and chart from it on each redraw, so two numbers on screen can't disagree. First Bites' food board, allergen cards, stats, milestones, planner and doctor report all come from one list of feedings. Baby Signs stores only "taught" and "signed" events, and a sign counts as known after 3 signed events on at least 2 days, because one fluke isn't a sign:
function knows(events, itemId) {
const signed = events.filter(e => e.itemId === itemId && e.kind === 'signed');
const days = new Set(signed.map(e => new Date(e.ts).toDateString())).size; // local days
return signed.length >= 3 && days >= 2;
}
Spaced repetition (showing a card just before you'd forget it) fits on top: levels 0 to 5, up on "Got it", down on "Again", low and overdue cards drawn most often, saved as {id: [level, due]}.
Save the real moment, show local time
Store Date.now(), the exact moment, and make it a date only for display: toDateString() gives the local day, while toISOString().slice(0, 10) gives the day in UTC, the clock most code defaults to, whose date flips during a U.S. afternoon or evening.
📍 First Bites pinned every entry to noon to dodge time zones, so every row read 12:00 PM, and those real times are gone for good. Grouping days in UTC made evening logs count as tomorrow, so the streak read 0 at night.
Make progress survive a refresh
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: each action appends an event and saves the list; on load, migrate() fills in what older saves lack.
Check it yourself in Chrome or Edge on a computer: mark something, refresh, press F12 (Mac: Cmd+Option+I), open Application (behind » if hidden), then Local storage → http://localhost:3000 in the left column: myapp.v1 holds your events. (Firefox calls it Storage; Safari needs its developer tools switched on; phones can't.) A private window starts empty, so offer an export (next section) for anything precious.
Free browser superpowers
Deep links in the URL hash
Put the current view after the # in the address, the part called the hash: #cat=money. Read it on load and on every change. No router (a library mapping addresses to screens), no server: the hash never leaves the browser.
function route() {
const m = location.hash.match(/cat=([a-z0-9-]+)/);
openDeck(m ? m[1] : 'all'); // openDeck is your app's own function
}
window.addEventListener('hashchange', route);
route();
// and when someone opens a deck inside the app:
history.replaceState(null, '', '#cat=' + key);
What's a pattern like [a-z0-9-]+?
A regular expression, a tiny language for matching text. [a-z0-9-] is one lowercase letter, digit or hyphen, and + means one or more.
📍 Acento's #cat= links open one deck. When deck keys became multi-word slugs like plata-joda-y-vicios, a letters-only pattern stopped at the first hyphen, and shared links silently opened the wrong deck. Match exactly your slugs' characters.
Instant search
With the data already loaded, search needs no server: filter on every keystroke. Acento's dictionary searches 900+ cards this way.
- Fold accents, but keep ñ:
queshould findqué, butaño(year) is notano, a different and rude word. - Escape the output, so a card's text can't break the layout or run code.
- Redraw only the results, never the box being typed in, or it loses focus after every letter. First Bites' search updates only its results area.
What does escaping mean?
Turning characters HTML treats as code (<, >, &, ") into harmless stand-ins like <, so the browser shows them instead of obeying them. textContent does it for you; innerHTML doesn't.
const fold = s => s.normalize('NFD')
.replace(/[\u0300-\u036f]/g, c => c === '\u0303' ? c : '') // drop accent marks, keep the tilde (\u0303) of ñ
.normalize('NFC').toLowerCase();
const esc = s => String(s ?? '').replace(/[&<>"]/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
const box = document.getElementById('q'), results = document.getElementById('results');
box.addEventListener('input', () => {
const q = fold(box.value.trim());
results.innerHTML = ITEMS // only the results area is redrawn, never the box
.filter(it => fold(it.text + ' ' + (it.note ?? '')).includes(q))
.map(it => '<p><b>' + esc(it.text) + '</b> ' + esc(it.note) + '</p>')
.join('');
});
Reports and exports
First Bites' pediatrician report and CSV export run entirely in the browser. The report is plain HTML, print-only CSS (@media print) and window.print(), which any device can save as a PDF. The CSV is text in a Blob (an in-memory file) behind a download link. Start it with a byte-order mark (an invisible first character, '\uFEFF' + csv) so Excel shows accents, and put an apostrophe before any cell starting with =, +, - or @, so no cell runs as a formula.
Check that a feature exists
Speech recognition is missing in Firefox, speech synthesis in some Firefox setups and in headless browsers (the invisible ones test scripts drive), and an unguarded call crashed Acento's page. The microphone needs HTTPS or localhost. Check first, and tell people what works:
const canSpeak = 'speechSynthesis' in window;
const canRecord = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition; // undefined where missing
Add deep links and search
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: one listener reads the hash on load and on every change; another filters on each keystroke and redraws one element. Check it yourself: paste an address with its # part into a new tab and the same view opens; type slowly into search and the cursor stays in the box.
Big libraries, still no build step
Static doesn't mean small. One of my side projects, a 3D physics game, shipped with zero npm installs: an import map tells the browser where to fetch modules like three.js (a 3D library) from a CDN, at pinned versions.
What are npm, a CDN and a module?
npm is Node's installer for add-on code. A CDN (content delivery network) serves files such as open-source libraries from servers around the world. A module is a JavaScript file that other code imports by name.
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
Pin exact versions, never @latest, so a release can't change your app overnight. Keep the trailing slash on both sides of the addons line, and preview over localhost: modules won't load from a double-click.
Vendor (keep your own copy of) the one library your app can't start without. Another side project loaded its map library, Leaflet, from a public CDN, so first visits waited on a fresh secure connection to someone else's server. A copy in vendor/ removed the wait:
mkdir vendor
curl -o vendor/leaflet.js https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
curl -o vendor/leaflet.css https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
<!-- Leaflet 1.9.4, vendored: upgrade on purpose -->
<link rel="stylesheet" href="vendor/leaflet.css">
<script src="vendor/leaflet.js"></script>
Default pin markers also need Leaflet's own images in vendor/images; ask Claude to download them from the same unpkg package. Tailwind and DaisyUI (ready-made styling) are fine from a CDN on a small page; Tailwind calls that build a development tool, so pin its version.
🦥 What we skip: React, bundlers and npm install. A framework is a decision you justify, not a default you reach for.
Do it with Claude: the prompts
| When you want | Ask Claude Code |
|---|---|
| The first version | Build your app as one index.html with inline CSS and JS, and the data in a JSON block between /*DATA_START*/ markers. No framework, no build step. |
| Saved progress | Save progress in localStorage under myapp.v1 as a list of events, run it through migrate() on load, and keep the theme under its own key. |
| Shareable links | Store the current view in the URL hash and restore it on load. |
Underneath all three: the browser downloads your files and runs them, so deploying is copying files (Deploy behind one front door). When Claude says it worked, ask how it knows, then look.
Your turn
- Your app's core loop works from one
index.htmlathttp://localhost:3000, served bynpx serve. - Change one item in the
DATAblock (or your CSV, then runnode build.mjs), refresh, and see it change. - Do something, refresh, and it's still there; DevTools shows
myapp.v1holding a list of events. - An address copied with its
#part opens the same view in a new tab, and search narrows the list as you type (if your text has accents,questill findsqué). - Write the backend rule at the top of
NOTES.md(from Work with Claude Code; create it if needed), "Backend only for a secret key, data shared between people, or a limit people can't cheat", and list below it any feature you want that hits it. - Ask Claude to commit this version;
git log --onelineshows it at the top.
Next, Check the AI's work: your rules move into engine.js with a self-check that runs in about a second, plus screenshots you actually look at.