In this tutorial you will build a small Todo CRUD app from scratch. By the end you will have:
- a
Todomodel with two fields, - three pages: list, new, and edit,
- three actions: create, toggle, and delete,
- an inline toggle that updates a single row over htmx without a full page reload.
The whole app fits in one .kilnx file. Useful references: model, page, action, fragment, query.
Create an empty file:
mkdir todo-app && cd todo-app
touch app.kilnxYou will edit app.kilnx step by step and re-run kilnx run app.kilnx after each step. The runtime auto-migrates the SQLite database, so the todo table appears the first time you start the server.
Open app.kilnx and add:
model todo
id: uuid auto
title: text required min 1 max 200
done: bool default false
created_at: timestamp auto
What is happening:
id: uuid autogives every row a generated UUID primary key. Seeauto.title: text required min 1 max 200enforces a non-empty title at most 200 chars. Seerequired,min,max.done: bool default falsedefaults new todos to unfinished. Seedefault.created_at: timestamp autostamps the row at insert time.
Run the server once to apply the migration:
kilnx run app.kilnxYou should see a log line about the todo table being created. Stop the server with Ctrl+C.
Add the index page that shows all todos:
page /todos title "Todos"
query todos: SELECT id, title, done FROM todo ORDER BY created_at DESC
html
<h1>Todos</h1>
<p><a href="/todos/new">New todo</a></p>
<ul id="todo-list">
{#each todos}
{> todo_row(todo=this)}
{/each}
</ul>
The page runs an inline query named todos and binds the rows to a template variable. Inside the {#each} loop, each row is rendered through a reusable component named todo_row. We define that component next.
Component fragments are declared with parentheses. They render a slice of HTML and are invoked with {> name(arg=value)}:
fragment todo_row(todo)
html
<li id="todo-{todo.id}" class="todo">
<input type="checkbox"
hx-post="/todos/{todo.id}/toggle"
hx-target="#todo-{todo.id}"
hx-swap="outerHTML"
{#if todo.done}checked{/if}>
<span class="{#if todo.done}done{/if}">{todo.title}</span>
<a href="/todos/{todo.id}/edit">edit</a>
<form method="post" action="/todos/{todo.id}/delete" style="display:inline">
<button type="submit">delete</button>
</form>
</li>
The checkbox uses three htmx attributes:
hx-postposts to the toggle endpoint when clicked,hx-targetsays which DOM node receives the response,hx-swap="outerHTML"replaces the whole<li>with what the server returns.
We will write that toggle response in step 7.
A page renders the form, an action processes the submission. Add both:
page /todos/new title "New todo"
html
<h1>New todo</h1>
<form method="post" action="/todos/create">
<label>Title <input name="title" required></label>
<button type="submit">Create</button>
<a href="/todos">Cancel</a>
</form>
action /todos/create method POST
validate todo
query: INSERT INTO todo (title) VALUES (:title)
redirect /todos
validate todo runs the model-level rules from step 2: it rejects empty titles and titles longer than 200 chars. See validate. On success the action inserts the row and sends the user back to the list.
Restart the server, visit http://localhost:8080/todos/new, submit a title, and you should land on /todos with one row visible.
The edit flow mirrors the new flow, except the form is preloaded and the SQL is an UPDATE:
page /todos/:id/edit title "Edit todo"
query todo: SELECT id, title, done FROM todo WHERE id = :id
html
<h1>Edit todo</h1>
<form method="post" action="/todos/{todo.id}/update">
<label>Title <input name="title" value="{todo.title}" required></label>
<label><input type="checkbox" name="done" {#if todo.done}checked{/if}> Done</label>
<button type="submit">Save</button>
<a href="/todos">Cancel</a>
</form>
action /todos/:id/update method POST
validate todo
query: UPDATE todo SET title = :title, done = :done WHERE id = :id
redirect /todos
The :id placeholder in the path becomes a parameter that the SQL can reference as :id. Same for :title and :done, which come from the form body.
This is the part that makes the list feel snappy. The checkbox in todo_row posts to /todos/:id/toggle. The action flips the done flag and asks the server to render only the updated row, which htmx then swaps in place.
action /todos/:id/toggle method POST
query: UPDATE todo SET done = NOT done WHERE id = :id
query todo: SELECT id, title, done FROM todo WHERE id = :id
respond
html
{> todo_row(todo=todo)}
What you should see when you click a checkbox at http://localhost:8080/todos:
- The browser issues a
POST /todos/<id>/toggle. - The action updates the row, reloads it, and renders the
todo_rowcomponent. - htmx replaces the matching
<li id="todo-<id>">with the new HTML. No full page reload.
If you prefer the explicit form, respond also accepts a CSS selector via respond fragment ".selector". The version above keeps the fragment lookup local to the action body, which is easier to reason about when you are starting out.
Same idea as toggle, except we redirect back to the list rather than swap a fragment:
action /todos/:id/delete method POST
query: DELETE FROM todo WHERE id = :id
redirect /todos
kilnx run app.kilnx.- Open
http://localhost:8080/todos. Empty list. - Click "New todo", create three todos.
- Click a checkbox. Only that row repaints. The text gains a
doneclass. - Click "edit" on any row, change the title, save. Back to the list with the new title.
- Click "delete". Row disappears.
If something goes wrong, run kilnx check app.kilnx to surface parse and type errors before the server even starts.
Save this as app.kilnx:
model todo
id: uuid auto
title: text required min 1 max 200
done: bool default false
created_at: timestamp auto
page /todos title "Todos"
query todos: SELECT id, title, done FROM todo ORDER BY created_at DESC
html
<h1>Todos</h1>
<p><a href="/todos/new">New todo</a></p>
<ul id="todo-list">
{#each todos}
{> todo_row(todo=this)}
{/each}
</ul>
fragment todo_row(todo)
html
<li id="todo-{todo.id}" class="todo">
<input type="checkbox"
hx-post="/todos/{todo.id}/toggle"
hx-target="#todo-{todo.id}"
hx-swap="outerHTML"
{#if todo.done}checked{/if}>
<span class="{#if todo.done}done{/if}">{todo.title}</span>
<a href="/todos/{todo.id}/edit">edit</a>
<form method="post" action="/todos/{todo.id}/delete" style="display:inline">
<button type="submit">delete</button>
</form>
</li>
page /todos/new title "New todo"
html
<h1>New todo</h1>
<form method="post" action="/todos/create">
<label>Title <input name="title" required></label>
<button type="submit">Create</button>
<a href="/todos">Cancel</a>
</form>
action /todos/create method POST
validate todo
query: INSERT INTO todo (title) VALUES (:title)
redirect /todos
page /todos/:id/edit title "Edit todo"
query todo: SELECT id, title, done FROM todo WHERE id = :id
html
<h1>Edit todo</h1>
<form method="post" action="/todos/{todo.id}/update">
<label>Title <input name="title" value="{todo.title}" required></label>
<label><input type="checkbox" name="done" {#if todo.done}checked{/if}> Done</label>
<button type="submit">Save</button>
<a href="/todos">Cancel</a>
</form>
action /todos/:id/update method POST
validate todo
query: UPDATE todo SET title = :title, done = :done WHERE id = :id
redirect /todos
action /todos/:id/toggle method POST
query: UPDATE todo SET done = NOT done WHERE id = :id
query todo: SELECT id, title, done FROM todo WHERE id = :id
respond
html
{> todo_row(todo=todo)}
action /todos/:id/delete method POST
query: DELETE FROM todo WHERE id = :id
redirect /todos
- Add login so each user only sees their own todos: Tutorial 2.
- Send a reminder email when a todo is overdue: Tutorial 3.