HTMX Interactions
The app works, but every click triggers a full page reload. HTMX fixes that — it swaps just the part of the page that changed, making the app feel instant. By the end of this page, clicking a note loads it in place, and creating, editing, and deleting notes all happen without leaving the page.
Step 1 — Add HTMX
HTMX is a JavaScript library that adds hx-get, hx-post, and similar attributes to HTML elements. When a user clicks an element with hx-get="/some/url", HTMX fetches that URL and swaps the response into a target element — no JavaScript to write.
Add the HTMX script tag to the page function in src/ui/layout.ks. Inside the headEl block, after the style tag:
script([attr("src", "https://unpkg.com/htmx.org@2.0.4")]) { nothing() }
nothing is html-builder's empty-body marker — add it to the html.builder import list at the top of layout.ks. That's it. HTMX is now available on every page.
Step 2 — HTMX attribute helpers
Create src/html/htmx.ks:
module notes.html
import html.builder.(Attr, attr)
public func hxGet(url: String) -> Attr {
attr("hx-get", url)
}
public func hxPost(url: String) -> Attr {
attr("hx-post", url)
}
public func hxDelete(url: String) -> Attr {
attr("hx-delete", url)
}
public func hxTarget(selector: String) -> Attr {
attr("hx-target", selector)
}
public func hxSwap(mode: String) -> Attr {
attr("hx-swap", mode)
}
public func hxPushUrl(url: String) -> Attr {
attr("hx-push-url", url)
}
public func hxConfirm(message: String) -> Attr {
attr("hx-confirm", message)
}
These return Attr values — the same type html-builder uses for cls, href, and friends. They slot right into the attribute arrays: div([cls("card"), hxGet("/notes")]). Thin wrappers, but they prevent typos and make templates readable.
Step 3 — Fragment endpoints
A fragment endpoint returns a chunk of HTML — no <!DOCTYPE>, no <html> wrapper. HTMX swaps this fragment into the existing page.
Add these to src/main.ks, reusing the same view functions:
import notes.html.(hxGet, hxPost, hxDelete, hxTarget, hxSwap, hxPushUrl, hxConfirm)
func handleNotesFragment(req: Request, ctx: AppCtx) -> Response {
let db = ctx.db;
guard let .Ok(notes) = listNotes(db) else { return Response.internalServerError() }
Response.ok(Html(noteListView(notes).render()))
}
func handleNoteFragment(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else { return Response.notFound() }
guard let .Some(id) = Int64(parsing: noteId) else { return Response.notFound() }
let db = ctx.db;
guard let .Ok(some note) = findNoteById(db, id: id) else { return Response.notFound() }
Response.ok(Html(noteDetailView(note).render()))
}
func handleEditFragment(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else { return Response.notFound() }
guard let .Some(id) = Int64(parsing: noteId) else { return Response.notFound() }
let db = ctx.db;
guard let .Ok(some note) = findNoteById(db, id: id) else { return Response.notFound() }
Response.ok(Html(noteEditorView(noteId, note.title, note.body).render()))
}
func handleNewFragment(req: Request, ctx: AppCtx) -> Response {
Response.ok(Html(noteEditorView("", "", "").render()))
}
func handleCreateFragment(req: Request, ctx: AppCtx) -> Response {
let form = parseForm(req.body);
let formTitle = formField(form, "title");
let formBody = formField(form, "body");
guard let .Ok(note) = createNote(ctx.db, formTitle, formBody) else {
return Response.internalServerError()
}
Response.ok(Html(noteDetailView(note).render()))
}
func handleUpdateFragment(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else { return Response.notFound() }
guard let .Some(id) = Int64(parsing: noteId) else { return Response.notFound() }
let form = parseForm(req.body);
let formTitle = formField(form, "title");
let formBody = formField(form, "body");
guard let .Ok(some note) = updateNote(ctx.db, id: id, formTitle, formBody) else {
return Response.notFound()
}
Response.ok(Html(noteDetailView(note).render()))
}
func handleDeleteFragment(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else { return Response.notFound() }
guard let .Some(id) = Int64(parsing: noteId) else { return Response.notFound() }
let _ = deleteNote(ctx.db, id: id);
guard let .Ok(notes) = listNotes(ctx.db) else { return Response.internalServerError() }
Response.ok(Html(noteListView(notes).render()))
}
Notice how handleNotesFragment and handleHome call the same noteListView function — the only difference is that handleHome wraps it in appShell for a full page, while the fragment calls .render() on the view directly. This reuse is the whole trick.
Register the fragment routes in main:
// HTMX fragments
app.route(get: "/fragments/notes", handleNotesFragment);
app.route(get: "/fragments/note/:id", handleNoteFragment);
app.route(get: "/fragments/note/:id/edit", handleEditFragment);
app.route(get: "/fragments/new", handleNewFragment);
app.route(post: "/fragments/new", handleCreateFragment);
app.route(post: "/fragments/note/:id", handleUpdateFragment);
app.route(delete: "/fragments/note/:id", handleDeleteFragment);
Step 4 — Update note cards
First give src/ui/notes.ks access to the helpers — add this to its imports:
import notes.html.(hxGet, hxPost, hxDelete, hxTarget, hxSwap, hxPushUrl, hxConfirm)
Then update noteCard to use HTMX instead of a plain link:
func noteCard(note: Note) -> Document {
let preview = if note.body.byteCount > 140 {
note.body.asSlice().subslice(from: 0, to: 140).toOwned() + "..."
} else {
note.body
};
let noteId = note.id.formatted();
let dateStr = formatNoteDate(note.createdAt);
div([cls("note-card"),
hxGet("/fragments/note/" + noteId),
hxTarget("#content"),
hxSwap("innerHTML"),
hxPushUrl("/note/" + noteId)]) {
div([cls("note-card-title")]) { text(note.title) } +
div([cls("note-card-preview")]) { text(preview) } +
div([cls("note-card-date")]) { text(dateStr) }
}
}
Now clicking a note card fetches the fragment and swaps it into #content without a page reload. hxPushUrl updates the browser URL bar so the back button works.
The HTMX attributes sit in the same Array[Attr] as cls — they're all Attr values, so they compose naturally.
Step 5 — Update the toolbar
In noteDetailView, update the Back, Edit, and Delete controls to use HTMX:
div([cls("note-toolbar")]) {
button([cls("btn"),
hxGet("/fragments/notes"),
hxTarget("#content"),
hxSwap("innerHTML"),
hxPushUrl("/")]) { text("Back") } +
div([cls("toolbar-actions")]) {
button([cls("btn"),
hxGet("/fragments/note/" + noteId + "/edit"),
hxTarget("#content"),
hxSwap("innerHTML"),
hxPushUrl("/note/" + noteId + "/edit")]) { text("Edit") } +
button([cls("btn btn-danger"),
hxDelete("/fragments/note/" + noteId),
hxTarget("#content"),
hxSwap("innerHTML"),
hxPushUrl("/"),
hxConfirm("Delete this note?")]) { text("Delete") }
}
}
The delete button adds hxConfirm — HTMX shows a browser confirmation dialog before sending the request. After deletion, the response is the updated note list, which replaces #content.
Step 6 — Update the editor form
In noteEditorView, update the form to submit via HTMX:
let hxAction = if isNew {
hxPost("/fragments/new")
} else {
hxPost("/fragments/note/" + noteId)
};
form([attr("method", "POST"), hxAction, hxTarget("#content"), hxSwap("innerHTML"),
hxPushUrl(if isNew { "/" } else { "/note/" + noteId })]) {
// ... fields unchanged ...
}
And update the "New Note" link in appShell (in layout.ks — this file needs the helper import too: import notes.html.(hxGet, hxTarget, hxSwap, hxPushUrl)):
button([cls("btn btn-primary"),
hxGet("/fragments/new"),
hxTarget("#content"),
hxSwap("innerHTML"),
hxPushUrl("/new")]) { text("New Note") }
Step 7 — Try it
Restart the server and open http://localhost:8080 in your browser. The difference is immediate:
- Click a note — the detail loads in place, no flash.
- Click Edit — the form appears without leaving the page.
- Save — you're back to the note detail, no reload.
- Delete — a confirmation dialog appears, then the list refreshes.
- The URL bar updates with every action. The browser back button works.
No JavaScript was written beyond loading the HTMX library. The server renders HTML fragments, HTMX swaps them in, and the browser history API keeps URLs in sync.
What you built
Over five pages you built:
| Layer | What |
|---|---|
| Database | SQLite schema with timestamps, query functions, row-to-struct mapping |
| Models | Structs conforming to FromRow, Serialize, Deserialize |
| API | REST endpoints for CRUD, with guard let chains |
| Frontend | Server-rendered HTML with the html-builder library and datetime formatting |
| Interactions | HTMX fragment swapping for SPA-like behavior |
The app is fully functional. The next page is optional — it adds user authentication with SHA-256 password hashing if you want to scope notes to individual users.