Capstone Project: One Feature, Every Layer
Every earlier chapter taught one layer in isolation. This chapter builds a single feature — adding an item to a shared list and seeing it appear instantly in every open browser tab — and traces it through all of them: a reactive component, a typed store, an HTTP API, a database write, and a real-time push back out. If you understand this loop, you understand the shape of the whole system.
A shared todo list. Any connected browser can add an item. The moment it's saved, every connected browser — including ones that didn't make the request — sees it appear, with no refresh and no polling.
The full loop #
Eight steps, crossing from the browser to the server and back. Follow the numbers in the diagram, then read the code for each step below it.
Steps 1–2: component & store #
The component only knows about the store. It has no idea an HTTP request or a database is involved — that separation is what lets you test the component and the store independently (see Chapter 15).
TodoForm.vue (script setup)const title = ref("");
const store = useTodoStore();
function submit() {
if (!title.value.trim()) return;
store.addTodo(title.value);
title.value = "";
}
stores/todos.ts — the action that starts the whole chain (same store as Chapter 8)
async function addTodo(title: string) {
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const created = await res.json();
todos.value.push(created); // this tab updates immediately
}
The tab that clicked "add" doesn't need the
SSE
event — it already has the item, from the fetch response, exactly as in
Chapter 8. The live event is for every other tab,
which never called addTodo and has no other way of knowing a row was inserted.
That's why step 8's handler, below, needs a duplicate check: this tab's own SSE event will still
arrive, just after the item is already there.
Steps 4–5: route handler & database #
routes/todos.tsapp.post<{ Body: { title: string } }>("/api/todos", async (req, reply) => {
const { title } = req.body;
if (!title?.trim()) return reply.code(400).send({ error: "title required" });
const id = crypto.randomUUID(); // global in modern Node, no import needed
const createdAt = Date.now();
try {
app.services.db
.prepare("INSERT INTO todos (id, title, done, created_at) VALUES (?, ?, 0, ?)")
.run(id, title, createdAt);
} catch (err) {
req.log.error(err, "failed to insert todo");
return reply.code(500).send({ error: "could not save todo" });
}
const todo = { id, title, done: false, createdAt };
app.services.liveBus.publish("todo-created", todo);
return reply.code(201).send(todo);
});
Two things happen here that matter more than the SQL itself: the handler reads shared services off
app.services (see Chapter 9's decorate
pattern) instead of importing a database singleton directly, and it publishes the exact same
todo object it just persisted — no second read from the database needed. The
liveBus.publish(name, payload) call is exactly the one from
Chapter 11.
Constructing todo by hand from the values already in scope, instead of re-reading the
row with a second SELECT, is a real, deliberate choice, not an oversight — it saves
a round trip. The assumption it rests on is that this object's shape never silently drifts from what a
real SELECT * FROM todos WHERE id = ? would return (a column added directly in SQL without
updating this handler, for instance, would break that). For a table this simple the assumption holds
easily; a table with server-computed columns (a default set by the database itself, a trigger) would
need the second read to stay accurate. Testing this route (see Chapter
15) is exactly how you'd catch that drift if it ever happened — assert the response matches
what a fresh read of the row actually returns.
Steps 6–8: broadcast, receive, re-render #
the same LiveBus from Chapter 11 — broadcasting to every connected tabpublish(eventName: string, payload: unknown) {
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.#clients) client.write(chunk);
}
wherever the app owns the EventSource connection — every tab, including tab A's
const events = new EventSource("/api/live");
const store = useTodoStore();
events.addEventListener("todo-created", (event) => {
store.applyRemoteTodo(JSON.parse(event.data));
});
This is the same store from Chapter 8 and the same
EventSource
wiring from Chapter 11. Nothing new is introduced here — this chapter's only job was showing you that they're the same pieces, connected. Notice this handler doesn't
reach into store.todos directly — it calls the same applyRemoteTodo
action from Chapter 8, which is also where the duplicate check lives: without it, tab A would end up
with two copies of the item it created.
Where the other chapters fit #
Types (Ch. 3)
A shared Todo interface, defined once and imported by both the route handler and
the store, keeps the client and server from silently drifting apart.
Monorepo (Ch. 4)
That shared interface lives in a package the server and web app both depend on — one source of truth, built before either consumer.
Migrations (Ch. 10)
The todos table itself came from a numbered migration, not a manual
CREATE TABLE run by hand once and forgotten.
Design tokens (Ch. 14)
The form and list use shared --space-*, --radius-*, and color tokens — not one-off pixel values — so they match the rest of the app automatically.
Plugin architecture (Ch. 13)
A "send a notification when a todo is added" feature could register itself against the same
todo-created event without this route ever knowing it exists.
Testing (Ch. 15)
The route can be tested by inserting into a temporary database and asserting on the response;
the store can be tested by feeding it a fake SSE event and asserting on state.todos.
Scheduling (Ch. 12)
A natural sixth piece: a scheduled job that ticks every few minutes, finds todos past a due
date, and calls liveBus.publish("todo-overdue", todo) — the exact same event
bus from step 6, just triggered by a timer instead of a route handler.
Secrets and encryption (Chapter 16) are the one piece genuinely outside this loop — a shared todo list has nothing to encrypt. That chapter's patterns apply the moment a feature stores something sensitive, which this one deliberately doesn't.
Try it yourself #
Extend the loop: add a done toggle. You'll need a second route
(PATCH /api/todos/:id), a second event type (todo-updated), a second store
handler branch, and a checkbox in the component. If you can trace that change through all eight
steps without looking back at this chapter, you've internalized the loop.
What you can do now #
"Zero to hero" doesn't mean you've memorized every API in this stack — it means you can look at an unfamiliar file in a project like this one and immediately answer: which layer is this, what does it depend on, and what would I break if I changed it? That's the skill this course actually taught. The syntax you can always look up.
- You can trace a user action from a click to a database row and back.
- You know why a type is shared instead of duplicated, and why a migration is numbered instead of edited in place.
- You know the difference between polling and a push channel, and when each is the right call.
- You know why a plugin system exists before you're ever asked to add a "one more special case" to a core file.
- When something breaks, you know which of these eight steps to put a breakpoint in first.
Keep the Glossary open in a tab. Go build something.