TriFrost + HTMX: Todo App
This example showcases a fully interactive, stateful todo application using TriFrost and HTMX.
โจ How It Works
This app is entirely server-rendered using TriFrost. Routing, HTML generation, and persistent state are all handled within a single index.tsx
entrypoint. Todos are stored durably using a DurableObjectCache
โ so no database setup is needed. TriFrost's built-in JSX support means UI fragments are returned directly from route handlers.
On the frontend, HTMX listens for form submissions and button clicks, then sends HTTP requests via attributes like hx-post
and hx-delete
. The server responds with fragment HTML โ e.g. just the updated todo list โ and HTMX swaps it seamlessly into the page.
๐งช Observability is baked in. Every request is traced using OpenTelemetry and exported to Uptrace. App metadata like name
and version
are included automatically in every span, with no boilerplate required. You can inspect the performance and behavior of each route in full detail.
๐ Project Structure
my-todos/
โโ public/
โ โโ main.css
โ โโ favicon.ico
โ โโ ...
โโ src/
โ โโ components/
โ โ โโ Footer.tsx
โ โ โโ Layout.tsx
โ โ โโ Todos.tsx
โ โโ index.tsx
โ โโ types.ts
โโ wrangler.toml
๐ง App Logic
The main app logic lives in src/index.tsx
, where TriFrost routes, renders, and manages state for the entire todo application using a Durable Objectโbacked cache.
// src/index.tsx
import {App, DurableObjectCache, OtelHttpExporter} from '@trifrost/core';
import {Layout} from './components/Layout';
import {TodoForm, TodoList, type Todo} from './components/Todos';
import {type Env} from './types';
export {TriFrostDurableObject} from '@trifrost/core';
const app = await new App<Env>({
name: "TriFrost_HTMX_Todos",
version: "1.0.0",
cache: new DurableObjectCache({store: ({env}) => env.MAIN_DURABLE}),
tracing: {exporters: ({env}) => [
new OtelHttpExporter({
logEndpoint: 'https://otlp.uptrace.dev/v1/logs',
spanEndpoint: 'https://otlp.uptrace.dev/v1/traces',
headers: {'uptrace-dsn': env.UPTRACE_DSN},
}),
]},
})
.get('/', async ctx => {
const todos = await ctx.cache.get<Todo[]>('todos') || [];
return ctx.html(<Layout>
<main>
<h1>๐ TriFrost: HTMX Todos</h1>
<TodoForm />
<TodoList todos={todos} />
</main>
</Layout>);
})
.post('/', async ctx => {
let todos = await ctx.cache.get<Todo[]>('todos') || [];
const text = String((ctx.body as {text:string}).text || '');
if (todos.findIndex(el => el.text === text) < 0) {
todos.push({id: crypto.randomUUID(), text});
todos = todos.slice(0, 50);
await ctx.cache.set('todos', todos);
}
return ctx.html(<TodoList todos={todos} />);
})
.del('/:id', async ctx => {
let todos = await ctx.cache.get<Todo[]>('todos') || [];
todos = todos.filter(t => t.id !== ctx.state.id);
await ctx.cache.set('todos', todos);
return ctx.html(<TodoList todos={todos} />);
})
.post('/complete', async ctx => {
const todos = await ctx.cache.get<Todo[]>('todos') || [];
const {done} = ctx.body as {done?: string|string[]};
const to_complete = new Set(typeof done === 'string' ? [done] : Array.isArray(done) ? done : []);
if (to_complete.size) {
for (const todo of todos) {
if (to_complete.has(todo.id)) todo.completed = true;
}
await ctx.cache.set('todos', todos);
}
return ctx.html(<TodoList todos={todos} />);
})
.boot();
export default app;
๐งฑ Layout
The layout component wraps full-page responses, injecting meta tags, global styles, the HTMX script, and optional UI elements like footers or loading indicators.
// src/components/Layout.tsx
export function Layout (props:{children:any}) {
return (<html lang="en">
<head>
<title>TriFrost & HTMX | Todos</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Manage your todos with TriFrost and HTMX" />
<script src="https://unpkg.com/htmx.org"></script>
<link rel="stylesheet" href="/main.css" />
</head>
<body>
{props.children}
<div class="htmx-indicator">โณ Loading...</div>
</body>
</html>);
}
๐งฉ Components
All UI elements like the todo form and todo list live in self-contained components under src/components/
. Each component renders server-side JSX and uses HTMX attributes to hook into behavior.
// src/components/Todos.tsx
export type Todo = {
id:string;
text:string;
completed?:boolean;
};
export function TodoForm () {
return (<form
hx-post="/"
hx-trigger="submit"
hx-target="#todo-list"
hx-swap="outerHTML"
>
<input
type="text"
name="text"
required
placeholder="Add new todo..."
className="form-el"
style={{flexGrow: 1}} />
<button type="submit">Add</button>
</form>);
}
export function TodoList (props:{children?:any; todos:Todo[]}) {
const todos = props.todos || [];
const hasTodos = todos.length > 0;
return (<section id="todo-list">
{!hasTodos && <p style={{
textAlign: 'center',
padding: '2rem',
backgroundColor: '#ededed',
borderRadius: '8px',
color: '#666',
}}><em>No todos yet</em></p>}
{hasTodos && (<form
hx-post="/complete"
hx-trigger="submit"
hx-target="#todo-list"
hx-swap="outerHTML"
>
<div style={{width: '100%'}}>
<ul style={{padding: '0', width: '100%'}}>
{todos.map(t => <li
key={t.id}
id={`todo-${t.id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '.5rem'
}}>
<input
type="checkbox"
name="done"
value={t.id}
disabled={t.completed}
className="form-el-small" />
<span style={{
textDecoration: t.completed ? 'line-through' : 'none'
}}>{t.text}</span>
<button
type="button"
className="el--small"
hx-delete={`/${t.id}`}
hx-target="#todo-list"
hx-swap="outerHTML"
>Remove</button>
</li>)}
</ul>
<button
type="submit"
style={{marginTop: '1rem'}}
>Complete</button>
</div>
</form>)}
</section>);
}
๐ง Environment
All environment bindings expected by the app โ such as the durable object, asset fetcher, and optional Uptrace DSN โ are defined in src/types.ts
for type safety and clarity.
This exported Env
type is also used as a generic for the App, this generic ensures each of the routes (as well as the app configuration itself) knows the environment.
Pro-tip: You can access the environment through ctx.env
in any route handler.
// src/types.ts
export type Env = {
ASSETS: Fetcher;
MAIN_DURABLE: DurableObjectNamespace;
UPTRACE_DSN: string; /* DSN from uptrace */
};
๐ง Cloudflare
The wrangler.toml
config defines how your app runs on Cloudflare โ including bindings for assets and durable objects, compatibility flags, and deployment metadata.
// wrangler.toml
name = "trifrost_htmx_todos"
main = "src/index.tsx"
compatibility_date = "2025-05-08"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./public/"
binding = "ASSETS"
[[durable_objects.bindings]]
name = "MAIN_DURABLE" # This is the name of our durable object (see src/types.ts)
class_name = "TriFrostDurableObject" # This is the class exported from src/index.ts
[[migrations]]
tag = "v1"
new_sqlite_classes = ["TriFrostDurableObject"] # This is a requirement by Cloudflare
๐ผ๏ธ Screenshots


๐ Resources
- HTMX: Add AJAX, WebSockets, and more to HTML using attributes.
- Cloudflare Durable Objects: Low-latency stateful storage at the edge.
- Uptrace: OTel-powered observability backend used in this example.
- HTML Forms (MDN): Useful refresher on native forms.