| name | templ-htmx |
| description | Build interactive hypermedia-driven applications with templ and HTMX. Use when creating dynamic UIs, real-time updates, AJAX interactions, mentions 'HTMX', 'dynamic content', or 'interactive templ app'. |
Templ + HTMX Integration
Overview
HTMX enables modern, interactive web applications with minimal JavaScript. Combined with templ's type-safe components, you get fast, reliable hypermedia-driven UIs.
Key Benefits:
- No JavaScript framework needed
- Server-side rendering
- Minimal client-side code
- Progressive enhancement
- Type-safe components
When to Use This Skill
Use when:
- Building interactive UIs
- Creating dynamic content
- User mentions "HTMX", "dynamic updates", "real-time"
- Implementing AJAX-like behavior without JS
- Building SPAs without frameworks
Quick Start
1. Add HTMX to Layout
package components
templ Layout(title string) {
<!DOCTYPE html>
<html>
<head>
<title>{ title }</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
{ children... }
</body>
</html>
}
2. Create Interactive Component
templ Counter(count int) {
<div>
<p>Count: { strconv.Itoa(count) }</p>
<button
hx-post="/counter/increment"
hx-target="#counter"
hx-swap="outerHTML"
>
Increment
</button>
</div>
}
3. Create Handler
func incrementHandler(w http.ResponseWriter, r *http.Request) {
count := getCount() + 1
saveCount(count)
components.Counter(count).Render(r.Context(), w)
}
Core HTMX Attributes
hx-get / hx-post
Trigger HTTP requests:
templ SearchBox() {
<input
type="text"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
/>
<div id="results"></div>
}
Handler:
func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
results := search(query)
components.SearchResults(results).Render(r.Context(), w)
}
hx-target
Specify where to insert response:
templ LoadMore(page int) {
<button
hx-get={ "/posts?page=" + strconv.Itoa(page) }
hx-target="#posts"
hx-swap="beforeend"
>
Load More
</button>
}
hx-swap
Control how content is swapped:
// innerHTML (default)
hx-swap="innerHTML"
// outerHTML - replace element itself
hx-swap="outerHTML"
// beforeend - append inside
hx-swap="beforeend"
// afterend - insert after
hx-swap="afterend"
hx-trigger
Control when requests fire:
// On click (default for buttons)
<button hx-get="/data">Click me</button>
// On change
<select hx-get="/filter" hx-trigger="change">
// On keyup with delay
<input hx-get="/search" hx-trigger="keyup changed delay:300ms">
// On page load
<div hx-get="/data" hx-trigger="load">
// Every 5 seconds
<div hx-get="/updates" hx-trigger="every 5s">
Common Patterns
Pattern 1: Live Search
Component:
templ SearchBox() {
<div>
<input
type="text"
name="q"
placeholder="Search..."
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
hx-indicator="#spinner"
/>
<span id="spinner" class="htmx-indicator">
Searching...
</span>
</div>
<div id="search-results"></div>
}
templ SearchResults(results []string) {
<ul>
for _, result := range results {
<li>{ result }</li>
}
</ul>
}
Handler:
func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
results := performSearch(query)
components.SearchResults(results).Render(r.Context(), w)
}
Pattern 2: Infinite Scroll
templ PostList(posts []Post, page int) {
<div id="posts">
for _, post := range posts {
@PostCard(post)
}
</div>
if len(posts) > 0 {
<div
hx-get={ "/posts?page=" + strconv.Itoa(page+1) }
hx-trigger="revealed"
hx-swap="outerHTML"
>
Loading more...
</div>
}
}
Pattern 3: Delete with Confirmation
templ DeleteButton(itemID string) {
<button
hx-delete={ "/items/" + itemID }
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="outerHTML swap:1s"
>
Delete
</button>
}
Handler:
func deleteHandler(w http.ResponseWriter, r *http.Request) {
itemID := strings.TrimPrefix(r.URL.Path, "/items/")
deleteItem(itemID)
// Return empty to remove element
w.WriteHeader(http.StatusOK)
}
Pattern 4: Inline Edit
templ EditableField(id string, value string) {
<div id={ "field-" + id }>
<span>{ value }</span>
<button
hx-get={ "/edit/" + id }
hx-target={ "#field-" + id }
hx-swap="outerHTML"
>
Edit
</button>
</div>
}
templ EditForm(id string, value string) {
<form
hx-post={ "/save/" + id }
hx-target={ "#field-" + id }
hx-swap="outerHTML"
>
<input type="text" name="value" value={ value } />
<button type="submit">Save</button>
<button
hx-get={ "/cancel/" + id }
hx-target={ "#field-" + id }
>
Cancel
</button>
</form>
}
Pattern 5: Form Validation
templ SignupForm() {
<form hx-post="/signup" hx-target="#form-errors">
<div id="form-errors"></div>
<input
type="email"
name="email"
hx-post="/validate/email"
hx-trigger="blur"
hx-target="#email-error"
/>
<div id="email-error"></div>
<input type="password" name="password" />
<button type="submit">Sign Up</button>
</form>
}
templ ValidationError(message string) {
<span class="error">{ message }</span>
}
Pattern 6: Polling / Real-time Updates
templ LiveStats() {
<div
hx-get="/stats"
hx-trigger="load, every 5s"
hx-swap="innerHTML"
>
Loading stats...
</div>
}
templ StatsDisplay(stats Stats) {
<div>
<p>Users online: { strconv.Itoa(stats.UsersOnline) }</p>
<p>Active sessions: { strconv.Itoa(stats.Sessions) }</p>
</div>
}
Advanced Patterns
Out-of-Band Updates (OOB)
Update multiple parts of page:
templ CartButton(count int) {
<button id="cart-btn">
Cart ({ strconv.Itoa(count) })
</button>
}
templ AddToCartResponse(item Item) {
// Main response
<div class="notification">
Added { item.Name } to cart!
</div>
// Update cart button (different part of page)
<div id="cart-btn" hx-swap-oob="true">
@CartButton(getCartCount())
</div>
}
Progressive Enhancement
templ Form() {
<form
action="/submit"
method="POST"
hx-post="/submit"
hx-target="#result"
>
<input type="text" name="data" />
<button type="submit">Submit</button>
</form>
<div id="result"></div>
}
Works without JavaScript, enhanced with HTMX.
Loading States
templ DataTable() {
<div
hx-get="/data"
hx-trigger="load"
hx-indicator="#loading"
>
<div id="loading" class="htmx-indicator">
Loading data...
</div>
</div>
}
CSS:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
Response Headers
HX-Trigger
Trigger client-side events:
func handler(w http.ResponseWriter, r *http.Request) {
// Do work...
// Trigger custom event
w.Header().Set("HX-Trigger", "itemCreated")
components.Success().Render(r.Context(), w)
}
Client side:
document.body.addEventListener("itemCreated", function(evt) {
console.log("Item created!");
});
HX-Redirect
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
HX-Refresh
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusOK)
}
Best Practices
- Keep handlers focused - Return only the HTML fragment needed
- Use semantic HTML - Works without JS
- Handle errors gracefully - Return error components
- Optimize responses - Send minimal HTML
- Use OOB for multi-updates - Update multiple page sections
- Progressive enhancement - Always provide fallback
Full Example: Todo App
// components/todo.templ
package components
type Todo struct {
ID string
Text string
Completed bool
}
templ TodoApp(todos []Todo) {
@Layout("Todo App") {
<div>
<h1>My Todos</h1>
@TodoForm()
@TodoList(todos)
</div>
}
}
templ TodoForm() {
<form
hx-post="/todos"
hx-target="#todo-list"
hx-swap="beforeend"
hx-on::after-request="this.reset()"
>
<input
type="text"
name="text"
placeholder="New todo..."
required
/>
<button type="submit">Add</button>
</form>
}
templ TodoList(todos []Todo) {
<ul id="todo-list">
for _, todo := range todos {
@TodoItem(todo)
}
</ul>
}
templ TodoItem(todo Todo) {
<li id={ "todo-" + todo.ID }>
<input
type="checkbox"
checked?={ todo.Completed }
hx-post={ "/todos/" + todo.ID + "/toggle" }
hx-target={ "#todo-" + todo.ID }
hx-swap="outerHTML"
/>
<span class={ templ.KV("completed", todo.Completed) }>
{ todo.Text }
</span>
<button
hx-delete={ "/todos/" + todo.ID }
hx-target={ "#todo-" + todo.ID }
hx-swap="outerHTML swap:500ms"
>
Delete
</button>
</li>
}
Handlers:
func todosHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
todos := getAllTodos()
components.TodoApp(todos).Render(r.Context(), w)
case "POST":
r.ParseForm()
todo := createTodo(r.FormValue("text"))
components.TodoItem(todo).Render(r.Context(), w)
}
}
func todoToggleHandler(w http.ResponseWriter, r *http.Request) {
id := extractID(r.URL.Path)
todo := toggleTodo(id)
components.TodoItem(todo).Render(r.Context(), w)
}
func todoDeleteHandler(w http.ResponseWriter, r *http.Request) {
id := extractID(r.URL.Path)
deleteTodo(id)
w.WriteHeader(http.StatusOK) // Empty response removes element
}
Resources
Next Steps
- Style components → Use
templ-cssskill - Deploy → Use
templ-deploymentskill - Test → Use
templ-testingskill