Personal Notes
Everything comes together. Auth, htmx, SQLite, components — a real app.
You will learn
- Build a full CRUD app with SQLite and htmx
- Return HTML fragments from API endpoints with App::renderToString()
- Wire htmx to add, list, and delete notes without page reloads
- Reuse components across different contexts (list, create, chat history)
The problem
You have users who can log in. Now they want to store things — each user seeing only their own notes, adding new ones, deleting old ones. No page reloads. This is the lesson where everything you've learned comes together.
Every data-driven app follows the CRUD loop: Create, Read, Update, Delete. Build this once, and you can build any data app — a todo list, a blog, an inventory system.
Sign in to your vault
No email needed — just pick a username and password. Lost the password? Make a new account.
New here? Register
The architecture
sequenceDiagram
participant B as Browser
participant H as htmx
participant API as /api/learn/notes
participant N as Notes.php
participant DB as SQLite
participant WS as WebSocket
B->>H: Submit form
H->>API: POST /api/learn/notes
API->>N: Notes::create($db, $userId, ...)
N->>DB: INSERT INTO notes
DB-->>N: id = 42
N-->>API: note row
API->>WS: broadcast(note_changed)
WS-->>B: push to all tabs
API-->>H: HTML card fragment
H-->>B: afterbegin swap (green glow)
Three layers, each with one job:
src/Learn/Notes.php— Business logic. SQL queries scoped byuser_id.api/learn/notes.php— Endpoint. Reads the request, calls the class, returns HTML.- Template + htmx — UI. The form and list, wired with four htmx attributes.
The data layer
Every method takes a $userId parameter. The user can never read or modify another user's notes:
// src/Learn/Notes.php
class Notes
{
public static function create(\PDO $db, int $userId, string $title, string $body): ?int
{
$stmt = $db->prepare(
'INSERT INTO notes (user_id, title, body, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$userId, trim($title), $body, time(), time()]);
return (int) $db->lastInsertId();
}
public static function list(\PDO $db, int $userId): array
{
$stmt = $db->prepare(
'SELECT id, title, body, updated_at FROM notes
WHERE user_id = ? ORDER BY updated_at DESC'
);
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
}
Introducing App::renderToString()
In Lesson 4, you learned App::render() which echoes HTML. But htmx sent a POST and
expects HTML back as the response body. You need the HTML as a string, not echoed to
the page. That's App::renderToString():
// api/learn/notes.php — return the rendered note card
$note = Notes::read($db, $userId, $id);
$html = App::renderToString('/components/_note_card', $note);
$this->response($html, 200);
Same component, same template file — but now you get the HTML as a string to return from your API.
The htmx wiring
The form above has four htmx attributes that replace 30+ lines of JavaScript:
<form hx-post="/api/learn/notes"
hx-target="#notes-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
hx-post— sends the form data as POSThx-target— which DOM element receives the responsehx-swap="afterbegin"— insert the new note as the first child (top of list)hx-on::after-request— clear the form after success
Delete uses a similar pattern:
<button hx-delete="/api/learn/notes/<?= $id ?>"
hx-target="#note-<?= $id ?>"
hx-swap="outerHTML"
hx-confirm="Delete this note?">Delete</button>
hx-swap="outerHTML" replaces the entire note card with the empty response — effectively removing it.
Component reuse
The _note_card component is used in three places: the notes list (GET), the create response (POST), and the chat history bubbles (Lesson 9). Same file, three contexts — that's the power of server-rendered components.
Cross-tab sync via WebSocket
When you add or delete a note, the server also broadcasts a note_changed event via WebSocket. Other browser tabs receive it and refresh their notes list with htmx.ajax(). Open this page in two tabs and try it — Lesson 10 explains how.
Key Takeaways
- CRUD is four verbs: Create, Read, Update, Delete — every data app follows this pattern
App::renderToString()returns HTML as a string for htmx fragment responses- Four htmx attributes replace 30+ lines of JavaScript for form submission
- User-scoped queries (
WHERE user_id = ?) ensure data isolation