● David R. Longnecker
Chapter 17 · Capstone

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.

The feature we're building

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.

Eight-step loop from a component event through the store, an HTTP API, the database, a live event bus, and back to every connected browser Browser tab A (client) 1. Component emits 2. Store action 3. fetch POST /api/todos → Browser tab B (client) 8. EventSource fires 8. store merges + dedupes re-render, item appears — no refresh Server (one long-running process) 4. Route handler 5. Prepared INSERT 6. Live bus publish 7. SSE broadcast dashed line: pushed to EVERY connected browser, not just the one that asked
One user action in tab A ends with an update appearing in tab B, with no request ever made by tab B.

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
}
Why does the live event matter if we already pushed locally?

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.ts
app.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 tab
publish(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 #

Exercise

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.

Keep the Glossary open in a tab. Go build something.