---
title: I made a sweet TODO app with Duso and HTMX
date: 2026-06-25
pinned: true
description: A simple CRUD demo web app that's only 164 lines of code and html. Features multi-sessions, persistence, and no dependencies. Includes link to a deep comparison vs. Node, Go, Python, Rails, PHP, and Rust.
image: /asset/duso-todo-mvp-comparison.png
dave_react: surprised
dave_pos: left
---

The "TODO MVP" is a rite of passage for programming languages and frameworks, it's simple enough to understand quickly but complex enough to show how a framework handles routing, state, persistence, and UI.

## TL;DR

This example is well featured and is only 164 lines of Duso code and HTML, which is really small. Even a fraction of some languages and frameworks (comparison below). This example shows Duso's simple approach backed by Go's legendary core libs.

- [example source repo](https://github.com/davebalmer/to-duso/tree/main)
- [comparison to some other languages](https://duso.rocks/docs/docs/todo-comparison.html)

## Features

- single-page web app
- multi-session (using sharable UUID urls)
- persistent data between runs
- basic todo CRUD operations
- mark items as complete
- client-side interactions using HTMX

## A quick brief about Duso

[Duso](https://duso.rocks) is a free, open source batteries-included runtime with its own scripting language built in Go. It features everything most server applications would need built into its single 10MB binary. The binary also includes all its docs, script module libs, and code examples. Plus it has its own LSP server and linter with markdown support included inside. Disclosure: I made Duso and I love it.

## The source code

Starting this project is basically download, write some code, look up a couple docs, and run it from the command line. It can also go production by adding SSL cert info to the HTTP server config, and bundled into its own single ~10MB binary with app scripts inside. We'll set deployment aside for this example, but you can read more about it in the docs.

### server.du

Our main script:

1. configures a datastore named "todo" for persistence
2. sets up an http server (defaults to port 8080)
3. sets up dynamic routes that work with HTMX
4. sets up a default static route for css/svg files
5. starts the server which stays running until the process is killed

```duso
datastore("todos", {
  persist = "/CWD/todo-data/todos.gob",
  wal = "/CWD/todo-data/todos.wal"
})

server = http_server()

server.route("GET", "/", "index.du")
server.route("GET", "/:session", "index.du")
server.route("POST", "/todo/:session", "create.du")
server.route("GET", "/todo/:session/:id", "edit.du")
server.route("PATCH", "/todo/:session/:id", "update.du")
server.route("DELETE", "/todo/:session/:id", "delete.du")
server.route("POST", "/toggle/:session/:id", "toggle.du")

server.static("/*", ".")

print("Server listening on http://localhost:8080")
server.start()
```

### index.du

1. assigns a UUID for the session if there isn't one
2. serves up the app web page
3. loads in css and HTMX
4. renders any existing todos

```duso
render = require("render.du")
ctx = context()
res = ctx.response()
req = ctx.request()

session_id = req.params.session
if not session_id then
  session_id = uuid()
  res.redirect("/" + session_id)
end

function render_items(items, session_id)
  html = ""
  for i = 0, len(items) - 1 do
    html = html + render.todo(session_id, i)
  end
  return html
end

items = datastore("todos").get(session_id) or []

res.html("""
  <!DOCTYPE html>
  <html>
  <head>
    <title>to-duso htmx example</title>
    <script src="https://unpkg.com/htmx.org"></script>
    <link rel="stylesheet" href="/css/style.css">
  </head>
  <body>
    <main class="container">
      <div id="help-btn" class="help-btn" onclick="document.querySelector('small').classList.toggle('show')"></div>
      <h1>to-duso</h1>
      <small>Multi-session todo mvp with <a href="https://duso.rocks">duso</a> and <a href="https://htmx.org">htmx</a>. New sessions get a sharable URL. Data is persisted for all sessions.</small>
      <form hx-post="/todo/{{session_id}}" hx-target="ul" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
        <fieldset role="group">
          <input type="text" name="title" placeholder="Add a new todo..." required>
          <button type="submit">Add</button>
        </fieldset>
      </form>
      <ul>
        {{render_items(items, session_id)}}
      </ul>
    </main>
  </body>
  </html>
""")
```

### render.du

This module renders individual todos and is used by most of the scripts.

1. connects to the "todos" datastore (in-memory, fast)
2. fetches todo by session and todo index
3. returns the rendered html for HTMX to display
4. modules return their API with a `return` statement, similar to an `export` in other languages

```duso
store = datastore("todos")

function render_todo(session_id, idx)
  items = store.get(session_id) or []
  item = items[idx]
  if not item then return "" end
  checked = item.completed ? "checked" : ""
  completed_class = item.completed ? "completed" : ""
  trash_icon = "<img src=\"icons/bold/trash-bold.svg\" class=\"icon-trash\">"
  return """
    <li class="{{completed_class}}">
      <input type="checkbox" {{checked}} hx-post="/toggle/{{session_id}}/{{idx}}" hx-swap="none">
      <span hx-get="/todo/{{session_id}}/{{idx}}" hx-target="this" hx-swap="outerHTML">{{item.title}}</span>
      <button class="delete" hx-delete="/todo/{{session_id}}/{{idx}}" hx-target="closest li" hx-swap="outerHTML">{{trash_icon}}</button>
    </li>
  """
end

return { todo = render_todo }
```

### create.du

1. adds a new todo to the array of todos stored for this session id
2. returns the rendered todo html to HTMX

```duso
render = require("render.du")
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
items = store.get(session_id) or []
idx = len(items)
push(items, {title = req.form.title, completed = false})
store.set(session_id, items)

res.html(render.todo(session_id, idx))
```

### toggle.du

1. fetches a todo based on session and id
2. changes its `completed` value
3. returns a success status to HTMX

```duso
ctx = context()
res = ctx.response()
req = ctx.request()
session_id = req.params.session
idx = tonumber(req.params.id)

store = datastore("todos")
items = store.get(session_id) or []

items[idx].completed = not items[idx].completed
store.set(session_id, items)
res.error(204)
```

### edit.du

1. fetch a todo's data by session and id
2. return an edit form to HTMX

```duso
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
idx = tonumber(req.params.id)
items = store.get(session_id) or []
item = items[idx]

res.html("""
  <form hx-patch="/todo/{{session_id}}/{{idx}}" hx-target="closest li" hx-swap="outerHTML">
    <fieldset role="group">
      <input type="text" name="title" value="{{item.title}}" required autofocus>
      <button type="submit">Save</button>
    </fieldset>
  </form>
""")
```

### update.du

1. fetches todo data by session and id
2. save new data to it in the datastore
3. return rendered todo with new data to HTMX

```duso
render = require("./render.du")
ctx = context()
res = ctx.response()
req = ctx.request()
session_id = req.params.session
idx = tonumber(req.params.id)

store = datastore("todos")
items = store.get(session_id) or []
items[idx].title = req.form.title
store.set(session_id, items)

res.html(render.todo(session_id, idx))
```

### delete.du

1. set the value for a todo to `nil` (render.du will not render it, this keeps sequential ids stable at a small data cost, also it's an MVP)
2. returns an empty string to HTMX if successful

```duso
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")

session_id = req.params.session
idx = tonumber(req.params.id)
items = store.get(session_id) or []

items[idx] = nil
store.set(session_id, items)

res.html("")
```

### About the boilerplate lines

You'll notice every route handler starts with the same 4 lines:

```duso
ctx = context()
res = ctx.response()
req = ctx.request()
store = datastore("todos")
```

They look a bit repetitive. I could hide them away in an `include()` file and shave off a few lines per handler. But I feel it's a better practice to leave things explicit rather than hide information to save a few lines of code.

## How this compares

Want to see how this stacks up? Here are the same TODO app features built in 7 different languages and frameworks, all with similar feature sets:

- **Duso**: ==164 LOC, 0 dependencies==
- **Node Express**: 285 LOC, ~170 dependencies
- **Go**: 296 LOC, 1 dependency
- **PHP**: 341 LOC, 3 dependencies
- **Rust Rocket**: 370 LOC, ~300 dependencies
- **Python Flask**: 621 LOC, 12 dependencies
- **Ruby on Rails**: 1,086 LOC, ~20 dependencies

Check out the [full TODO App Framework Comparison](https://duso.rocks/docs/docs/todo-comparison.html) for detailed breakdowns including learning curve, stack thickness, and other observations.
