HTMX#
HTMX integration for hypermedia-driven applications with minimal JavaScript. Litestar-Vite provides seamless integration with the litestar-htmx extension.
At a Glance#
Template:
litestar assets init --template htmxMode:
template(orhtmx) withHTMXPluginEntry:
resources/main.js(minimal JS)Dev:
litestar run --reload(orlitestar assets serve+litestar run)
Quick Start#
litestar assets init --template htmx
Project Structure#
my-app/
├── app.py # Litestar backend with HTMX
├── package.json
├── vite.config.ts
├── templates/
│ ├── base.html.j2 # Base template with Vite + HTMX
│ ├── index.html.j2 # Main page
│ └── partials/
│ └── book_card.html.j2 # Reusable fragment
└── resources/
├── main.js # Entry (minimal)
└── styles.css
Backend Setup#
HTMX applications use mode="template" with the HTMXPlugin from litestar-htmx:
vite = VitePlugin(
config=ViteConfig(
mode="template",
dev_mode=DEV_MODE,
paths=PathConfig(root=here, resource_dir="resources"),
types=TypeGenConfig(output=Path("resources/generated"), generate_sdk=False),
runtime=RuntimeConfig(port=5050),
)
)
templates = TemplateConfig(directory=here / "templates", engine=JinjaTemplateEngine)
app = Litestar(route_handlers=[LibraryController], plugins=[vite, HTMXPlugin()], template_config=templates, debug=True)
Key points:
mode="template"enables Jinja2 template renderingHTMXPlugin()adds HTMX-specific request/response handlingTemplates use
.html.j2extension (Jinja2)
Base Template#
The base template sets up Vite HMR and the Litestar HTMX extension:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token | default('') }}" />
<title>{% block title %}{{ project_name }}{% endblock %}</title>
{{ vite_hmr() }}
{{ vite('resources/main.js') }}
</head>
<body hx-ext="litestar">
{% block content %}{% endblock %}
</body>
</html>
Key features:
{{ vite_hmr() }}- Enables hot module replacement in development{{ vite('resources/main.js') }}- Loads bundled assetshx-ext="litestar"- Enables the Litestar HTMX extension for JSON templatingcsrf_token- CSRF protection for forms and HTMX requests
Frontend Entry Point#
The Litestar HTMX extension must be registered explicitly from your Vite entry file:
import htmx from "htmx.org"
import "./styles.css"
// Register the Litestar HTMX extension so JSON templating and CSRF headers work automatically
import { registerHtmxExtension } from "litestar-vite-plugin/helpers"
// Import type-safe route helper for use in JSON templates
import { route } from "./generated/routes"
window.htmx = htmx
// Make route() available globally for JSON template expressions
// Templates can now use: route("book_detail", { book_id: book.id })
window.route = route
registerHtmxExtension()
htmx.process(document.body)
Key points:
window.htmx = htmxexposes the HTMX runtime to the extensionregisterHtmxExtension()wires in CSRF header injection and JSON templating supporthtmx.process(document.body)activates declarative HTMX behavior after the bundle loads
JSON event streams#
For a JSON WebSocket or SSE endpoint, register <litestar-stream> instead of
adding htmx-ext-ws or htmx-ext-sse:
import { defineStreamElement } from "litestar-vite-plugin/helpers"
defineStreamElement()
The element binds the connection to DOM lifetime and can render frames through
the existing swap="json" template engine. It replaces the htmx stream
extensions rather than layering another reconnect policy on top of them. See
WebSocket and SSE event streams.
HTMX Fragments#
Return partial HTML for HTMX swaps using HTMXTemplate:
@get("/fragments/book/{book_id:int}")
async def book_fragment(self, book_id: FromPath[int]) -> Template:
"""Return a book card fragment for HTMX swaps.
Returns:
The result.
"""
book = _get_book(book_id)
return HTMXTemplate(
template_name="partials/book_card.html.j2",
context={"book": book},
re_target="#book-detail",
re_swap="innerHTML",
push_url=False,
)
The HTMXTemplate response allows:
re_target- Override the target elementre_swap- Override the swap methodpush_url- Control browser history
Partial Template#
Fragment templates are simple Jinja2 partials:
<article class="space-y-2 rounded-xl border border-slate-200 bg-linear-to-b from-white to-slate-50 p-4 shadow-sm">
<h3 class="text-lg font-semibold text-[#202235]">{{ book.title }}</h3>
<p class="mt-1 text-slate-600">{{ book.author }} • {{ book.year }}</p>
<p class="mt-1 text-sm text-[#202235]">{{ book.tags | join(' · ') }}</p>
<button
class="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
hx-get="/fragments/book/{{ book.id }}"
hx-target="#book-detail"
hx-swap="innerHTML"
>
Refresh details
</button>
</article>
JSON Templating (Litestar Extension)#
The hx-ext="litestar" extension enables client-side JSON templating
using hx-swap="json" with template directives:
<!-- Fetch JSON and render client-side -->
<button hx-get="/api/books" hx-target="#books" hx-swap="json">
Load Books
</button>
<div id="books">
<!-- ls-for iterates over JSON array -->
<template ls-for="book in $data" ls-key="book.id">
<article>
<h3>${book.title}</h3>
<p>${book.author} - ${book.year}</p>
</article>
</template>
</div>
Template directives:
ls-for="item in $data"- Iterate over JSON response ($datais the response)ls-key="item.id"- Unique key for efficient updatesls-if="condition"- Conditional renderingls-else- Else branch for conditionals${expression}- Interpolate values with the fixed expression grammar
This enables hybrid rendering: server-side HTML for initial load, client-side templating for dynamic updates from JSON APIs.
The expression grammar supports property access, arithmetic, comparisons,
logical and ternary operators, string template and object literals, method
calls on values, calls to context functions, and the JSON and Math
namespaces. Assignment, new, function and arrow expressions, computed
indexing such as obj[key], array literals, and arbitrary browser globals
are intentionally unsupported.
In @event handlers, $event is a sanitized plain object rather than the
raw DOM event. It exposes type, target.value, target.name,
target.checked, target.id, target.type, preventDefault(), and
stopPropagation(). Host-object paths such as $event.view and
$event.target.ownerDocument are intentionally unavailable.
HTMX Patterns#
Inline Editing:
<div hx-get="/edit/{{ item.id }}"
hx-trigger="click"
hx-swap="outerHTML">
{{ item.name }}
</div>
Form Submission:
<form hx-post="/items"
hx-target="#items"
hx-swap="beforeend">
<input name="name" required>
<button type="submit">Add</button>
</form>
Delete with Confirmation:
<button hx-delete="/items/{{ item.id }}"
hx-confirm="Delete this item?"
hx-target="closest li"
hx-swap="outerHTML">
Delete
</button>
Why HTMX?#
Minimal JavaScript: Most interactivity via HTML attributes
Server-rendered: Full HTML responses, great for SEO
Progressive enhancement: Works without JS (degrades gracefully)
Simple mental model: Request → HTML response → DOM update
JSON templating: Client-side rendering when needed via Litestar extension