Examples
+

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

Homepage of the example
Homepage of the example
Example Uptrace Otel Logs
Example Uptrace Otel Logs

๐Ÿ“š Resources