Comparison with Other Approaches
| Feature | HTMX | React | Vue 3 | Alpine.js |
|---|---|---|---|---|
| Bundle Size | ~14 KB | ~44 KB | ~34 KB | ~15 KB |
| JavaScript Required | Minimal | Heavy | Moderate | Light |
| Server Round-trips | Yes | No (SPA) | No (SPA) | No |
| SEO | Excellent | Needs SSR | Needs SSR | Good |
| Learning Curve | Gentle | Steep | Moderate | Gentle |
| Build Step | Optional | Required | Required | None |
| Offline Support | Limited | Excellent | Excellent | Limited |
| Server Dependency | High | Low | Low | Medium |
| Best For | CRUD apps, forms | Complex UIs | Interactive apps | Progressive enhancement |
| Backend Integration | Seamless | API-based | API-based | API-based |
| Progressive Enhancement | Excellent | Poor | Poor | Good |
Quick Start
Create a new HTMX project with the CLI:mizu new ./my-htmx-app --template frontend/htmx
cd my-htmx-app
make dev
http://localhost:3000 to see your app!
Why HTMX?
The Hypermedia Approach
HTMX embraces the original web model: server-rendered HTML with progressive enhancement. Instead of sending JSON over the wire and building UI in JavaScript, HTMX sends ready-to-render HTML fragments. Traditional SPA approach:Client Request → JSON Response → JavaScript Renders UI
Client Request → HTML Response → Browser Renders UI
Key Benefits
- Tiny: ~14kB minified and gzipped
- No Build Step: Include via CDN or npm
- Server-First: All logic on the server
- Progressive: Works without JavaScript
- Accessible: Built on web standards
- Simple: Extends HTML with attributes
- SEO-Friendly: Real HTML from server
- Fast Development: No frontend/server coordination
When HTMX Shines
HTMX is perfect when you want:- Server-side rendering with dynamic interactions
- Simple mental model (HTML in, HTML out)
- No build toolchain
- Progressive enhancement
- Great SEO out of the box
- Rapid development with familiar tools
Installation
Via CDN
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js"></script>
Via npm
npm install htmx.org
import 'htmx.org'
import htmx from 'htmx.org'
window.htmx = htmx
Architecture
Development Mode
┌─────────────────────────────────────────────────────────────┐
│ Browser (Port 3000) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ HTML + HTMX │ │
│ │ │ │
│ │ <button hx-get="/users" hx-target="#list">Load</button>│ │
│ │ │ │
│ │ User clicks → HTMX sends GET /users │ │
│ │ Server responds with HTML fragment │ │
│ │ HTMX swaps content into #list │ │
│ └────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ HTTP Request │
│ │ (AJAX with HX-Request header) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Static Assets │ │
│ │ CSS, Images, HTMX library │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Mizu Backend (Go Server) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Router │ │
│ │ │ │
│ │ GET / → Render full page │ │
│ │ GET /users → Render HTML fragment │ │
│ │ POST /users → Process + return HTML │ │
│ │ PUT /users/{id} → Update + return HTML │ │
│ │ DELETE /users/{id} → Remove + return empty │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ View Engine │ │
│ │ │ │
│ │ • Templates (Go html/template) │ │
│ │ • Layouts (default.html) │ │
│ │ • Pages (home.html, users.html) │ │
│ │ • Partials (user-list.html, user-row.html) │ │
│ │ • Hot reload in dev mode │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Business Logic │ │
│ │ │ │
│ │ • Handlers (handleUsers, handleCreateUser) │ │
│ │ • Services (UserService, AuthService) │ │
│ │ • Validation │ │
│ │ • Database access │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Database │ │
│ │ PostgreSQL, SQLite, etc. │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Production Mode
┌─────────────────────────────────────────────────────────────┐
│ CDN / Edge │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Static Assets (Cached) │ │
│ │ • CSS, Images │ │
│ │ • HTMX library │ │
│ │ • Versioned and fingerprinted │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Load Balancer │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Mizu Server 1│ │ Mizu Server 2│ │ Mizu Server 3│
│ │ │ │ │ │
│ Single Binary│ │ Single Binary│ │ Single Binary│
│ • Embedded │ │ • Embedded │ │ • Embedded │
│ Templates │ │ Templates │ │ Templates │
│ • Embedded │ │ • Embedded │ │ • Embedded │
│ Assets │ │ Assets │ │ Assets │
│ • Handlers │ │ • Handlers │ │ • Handlers │
│ • Middleware │ │ • Middleware │ │ • Middleware │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└───────────────┼───────────────┘
▼
┌──────────────────┐
│ Database │
│ (with pooling) │
└──────────────────┘
Project Structure
my-htmx-app/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── app/
│ └── server/
│ ├── app.go # Mizu app setup
│ ├── config.go # Configuration
│ ├── routes.go # Routes
│ └── handlers.go # Request handlers
├── views/ # HTML templates
│ ├── embed.go # Embed directive
│ ├── layouts/
│ │ └── default.html # Base layout
│ ├── pages/
│ │ ├── home.html # Home page
│ │ └── about.html # About page
│ └── partials/ # Reusable components
│ ├── user-list.html # User list fragment
│ └── user-row.html # Single user row
├── static/ # CSS, JS, images
│ ├── embed.go
│ ├── css/
│ │ └── app.css
│ └── js/
│ └── app.js
├── go.mod
└── Makefile
Core Concepts
HTMX Attributes
HTMX extends HTML with attributes that enable AJAX, WebSockets, and Server-Sent Events directly in your markup.HTTP Methods
<!-- GET request -->
<button hx-get="/users">Load Users</button>
<!-- POST request -->
<form hx-post="/users">
<input name="name">
<button type="submit">Create</button>
</form>
<!-- PUT request -->
<button hx-put="/users/1">Update</button>
<!-- PATCH request -->
<button hx-patch="/users/1">Patch</button>
<!-- DELETE request -->
<button hx-delete="/users/1">Delete</button>
Targeting
Control where the response gets inserted:<!-- Target by ID -->
<button hx-get="/users" hx-target="#user-list">Load</button>
<div id="user-list"></div>
<!-- Target closest parent -->
<tr>
<td>Alice</td>
<td>
<button hx-delete="/users/1" hx-target="closest tr">Delete</button>
</td>
</tr>
<!-- Target this element -->
<div hx-get="/refresh" hx-target="this">Refresh</div>
<!-- Target next sibling -->
<button hx-get="/more" hx-target="next .content">Load More</button>
<div class="content"></div>
<!-- Target previous sibling -->
<button hx-get="/prev" hx-target="previous .content">Load Previous</button>
Swap Strategies
Control how content is swapped:<!-- Replace inner HTML (default) -->
<div hx-get="/users" hx-swap="innerHTML">Content</div>
<!-- Replace entire element -->
<div hx-get="/users" hx-swap="outerHTML">Content</div>
<!-- Insert before element -->
<div hx-get="/users" hx-swap="beforebegin">Content</div>
<!-- Insert after element -->
<div hx-get="/users" hx-swap="afterend">Content</div>
<!-- Insert at start of children -->
<ul hx-get="/users" hx-swap="afterbegin">...</ul>
<!-- Insert at end of children -->
<ul hx-get="/users" hx-swap="beforeend">...</ul>
<!-- Delete the element -->
<div hx-delete="/users/1" hx-swap="delete">Delete Me</div>
<!-- Don't swap (useful for side effects) -->
<button hx-post="/track" hx-swap="none">Track</button>
Swap Modifiers
Fine-tune swap behavior:<!-- Scroll to top after swap -->
<div hx-get="/users" hx-swap="innerHTML scroll:top">Content</div>
<!-- Scroll to bottom -->
<div hx-get="/messages" hx-swap="beforeend scroll:bottom">Messages</div>
<!-- Show for 1 second then swap -->
<div hx-get="/users" hx-swap="innerHTML show:top">Content</div>
<!-- Swap after delay -->
<div hx-get="/users" hx-swap="innerHTML swap:1s">Content</div>
<!-- Settle after delay -->
<div hx-get="/users" hx-swap="innerHTML settle:2s">Content</div>
<!-- Ignore title in response -->
<div hx-get="/users" hx-swap="innerHTML ignoreTitle:true">Content</div>
<!-- Focus on element after swap -->
<div hx-get="/form" hx-swap="innerHTML focus-scroll:true">Content</div>
Triggers
Control what triggers the request:<!-- Click (default for buttons) -->
<button hx-get="/users" hx-trigger="click">Load</button>
<!-- Multiple events -->
<input hx-get="/search" hx-trigger="keyup, change">
<!-- Delay -->
<input hx-get="/search" hx-trigger="keyup delay:500ms">
<!-- Throttle (max once per interval) -->
<input hx-get="/search" hx-trigger="keyup throttle:1s">
<!-- Changed (only if value changed) -->
<input hx-get="/search" hx-trigger="keyup changed">
<!-- From another element -->
<input type="text" id="search">
<div hx-get="/results" hx-trigger="keyup from:#search">Results</div>
<!-- On load -->
<div hx-get="/news" hx-trigger="load">Loading...</div>
<!-- On reveal (when scrolled into view) -->
<div hx-get="/more" hx-trigger="revealed">Load more...</div>
<!-- Every N seconds (polling) -->
<div hx-get="/status" hx-trigger="every 2s">Checking...</div>
<!-- Intersection observer -->
<div hx-get="/lazy" hx-trigger="intersect once">Load when visible</div>
<!-- Consume event (prevent default) -->
<form hx-post="/users" hx-trigger="submit consume">
<button type="submit">Submit</button>
</form>
Trigger Filters
Add conditions to triggers:<!-- Only trigger if Ctrl key is pressed -->
<div hx-get="/users" hx-trigger="click[ctrlKey]">Ctrl+Click to load</div>
<!-- Only if shift key -->
<div hx-get="/users" hx-trigger="click[shiftKey]">Shift+Click to load</div>
<!-- Only if specific mouse button -->
<div hx-get="/users" hx-trigger="click[button==0]">Left-click only</div>
<!-- Check input value -->
<input hx-get="/search" hx-trigger="keyup[target.value.length > 3]">
<!-- Multiple conditions -->
<button hx-get="/users" hx-trigger="click[ctrlKey && shiftKey]">
Ctrl+Shift+Click
</button>
Headers
Request Headers
HTMX automatically sends these headers:func handler(c *mizu.Ctx) error {
// Check if this is an HTMX request
isHTMX := c.Request().Header.Get("HX-Request") == "true"
// Get the ID of the target element
target := c.Request().Header.Get("HX-Target")
// Get the ID of the triggered element
trigger := c.Request().Header.Get("HX-Trigger")
// Get the name attribute of the triggered element
triggerName := c.Request().Header.Get("HX-Trigger-Name")
// Get the current URL
currentURL := c.Request().Header.Get("HX-Current-URL")
// Check if this is a history restore request
isHistory := c.Request().Header.Get("HX-History-Restore-Request") == "true"
// Get the user response to hx-prompt
prompt := c.Request().Header.Get("HX-Prompt")
if isHTMX {
// Return HTML fragment for HTMX requests
return c.Render("partials/users", data)
}
// Return full page for direct navigation
return c.Render("pages/users", data)
}
Response Headers
Control HTMX behavior from the server:func handler(c *mizu.Ctx) error {
// Trigger a client-side redirect
c.Writer().Header().Set("HX-Redirect", "/login")
// Refresh the page
c.Writer().Header().Set("HX-Refresh", "true")
// Replace the URL in browser history
c.Writer().Header().Set("HX-Replace-Url", "/users/page/2")
// Push new URL to history
c.Writer().Header().Set("HX-Push-Url", "/users/123")
// Re-target the response
c.Writer().Header().Set("HX-Retarget", "#different-element")
// Change swap strategy
c.Writer().Header().Set("HX-Reswap", "outerHTML")
// Trigger client-side events
c.Writer().Header().Set("HX-Trigger", "userCreated")
// Trigger after swap
c.Writer().Header().Set("HX-Trigger-After-Swap", "updateStats")
// Trigger after settle
c.Writer().Header().Set("HX-Trigger-After-Settle", "focusInput")
// Trigger with JSON payload
c.Writer().Header().Set("HX-Trigger", `{"showMessage": {"level": "info", "text": "User created"}}`)
return c.Render("partials/user", user)
}
Indicators
Show loading states:<!-- Default indicator -->
<button hx-get="/users">
<span class="htmx-indicator">Loading...</span>
Load Users
</button>
<!-- External indicator -->
<div id="spinner" class="htmx-indicator">
<img src="/spinner.gif" alt="Loading...">
</div>
<button hx-get="/users" hx-indicator="#spinner">Load</button>
<!-- CSS-based indicator -->
<style>
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-indicator {
display: none;
}
</style>
<button hx-get="/users">
<span class="htmx-indicator">⏳</span>
Load Users
</button>
Validation
Client-side validation still works:<form hx-post="/users" hx-target="#result">
<input
type="text"
name="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
>
<button type="submit">Submit</button>
</form>
<div id="result"></div>
func handleCreateUser(c *mizu.Ctx) error {
email := c.FormValue("email")
// Validate
if email == "" {
c.Writer().Header().Set("HX-Retarget", "#error")
c.Writer().Header().Set("HX-Reswap", "innerHTML")
return c.HTML(400, `<div class="error">Email is required</div>`)
}
if !isValidEmail(email) {
c.Writer().Header().Set("HX-Retarget", "#error")
return c.HTML(400, `<div class="error">Invalid email format</div>`)
}
// Create user...
return c.Render("partials/user-row", user)
}
<form hx-post="/users" hx-target="#user-list" hx-swap="beforeend">
<div id="error"></div>
<input name="email" type="email" placeholder="Email">
<button type="submit">Create</button>
</form>
Advanced Features
Out-of-Band Swaps
Update multiple parts of the page from a single response:<!-- Main target -->
<div id="user-list"></div>
<!-- Also update this -->
<div id="notification"></div>
<button hx-get="/users" hx-target="#user-list">Load Users</button>
<!-- Main content (goes to hx-target) -->
<div id="user-list">
<div>Alice</div>
<div>Bob</div>
</div>
<!-- Out-of-band swap (goes to #notification) -->
<div id="notification" hx-swap-oob="true">
Loaded 2 users
</div>
<div id="users">...</div>
<div id="stats" hx-swap-oob="true">Users: 2</div>
<div id="notification" hx-swap-oob="beforeend">
<div class="toast">Users loaded</div>
</div>
History and Navigation
Control browser history:<!-- Push URL to history (default for links) -->
<a hx-get="/users/1" hx-push-url="true">View User</a>
<!-- Push different URL -->
<button hx-get="/users/1" hx-push-url="/users/alice">View</button>
<!-- Replace current URL (no new history entry) -->
<button hx-get="/users/1" hx-replace-url="true">View</button>
<!-- Disable history -->
<button hx-get="/users" hx-push-url="false">View</button>
func handleUsers(c *mizu.Ctx) error {
// Check if this is a history restore
if c.Request().Header.Get("HX-History-Restore-Request") == "true" {
// Return full page state
return c.Render("pages/users", data)
}
// Normal HTMX request
return c.Render("partials/user-list", data)
}
Synchronization
Prevent concurrent requests:<!-- Queue requests to this element -->
<button hx-get="/slow-endpoint" hx-sync="this:queue">Load</button>
<!-- Drop new requests while one is in flight -->
<button hx-get="/endpoint" hx-sync="this:drop">Load</button>
<!-- Abort current request and make new one -->
<button hx-get="/endpoint" hx-sync="this:abort">Load</button>
<!-- Replace current request -->
<button hx-get="/endpoint" hx-sync="this:replace">Load</button>
<!-- Sync with another element -->
<input hx-get="/search" hx-sync="#search-form:abort">
Confirmation
Prompt before request:<!-- Simple confirmation -->
<button
hx-delete="/users/1"
hx-confirm="Are you sure you want to delete this user?"
>
Delete
</button>
<!-- Access prompt value on server -->
<button
hx-delete="/users/1"
hx-prompt="Enter your password to confirm"
>
Delete
</button>
func handleDelete(c *mizu.Ctx) error {
// Get prompt response
password := c.Request().Header.Get("HX-Prompt")
if password != "admin" {
return c.HTML(403, `<div>Invalid password</div>`)
}
// Delete user...
return c.HTML(200, "")
}
Boosting
Progressively enhance regular links and forms:<!-- Boost all links in this div -->
<div hx-boost="true">
<a href="/about">About</a>
<a href="/contact">Contact</a>
</div>
<!-- Boost a form -->
<form action="/users" method="post" hx-boost="true">
<input name="name">
<button type="submit">Create</button>
</form>
- Links become AJAX requests that target
body - Forms submit via AJAX
- URLs are pushed to history
- Works without JavaScript (progressive enhancement)
Preserving Content
Preserve elements during swaps:<!-- This element won't be replaced during swaps -->
<div hx-preserve="true" id="video-player">
<video src="/video.mp4" controls></video>
</div>
<div hx-get="/content" hx-target="body">
<!-- Video player will be preserved -->
</div>
Disable During Request
Disable elements while request is in flight:<!-- Disable this button -->
<button hx-post="/users" hx-disable>Create User</button>
<!-- Disable other elements -->
<form hx-post="/users">
<input name="name" hx-disable-elt="find button">
<button type="submit">Create</button>
</form>
<!-- Disable multiple elements -->
<form hx-post="/users" hx-disable-elt="find input, find button">
<input name="name">
<input name="email">
<button type="submit">Create</button>
</form>
Request Parameters
Include additional parameters:<!-- Include specific values -->
<button
hx-get="/users"
hx-vals='{"page": 1, "sort": "name"}'
>
Load Users
</button>
<!-- Include from JavaScript -->
<button
hx-get="/users"
hx-vals="js:{token: getAuthToken()}"
>
Load Users
</button>
<!-- Include nearby inputs -->
<input type="text" name="search" value="alice">
<button hx-get="/users" hx-include="[name='search']">Search</button>
<!-- Include form -->
<form id="filters">
<input name="status" value="active">
<input name="role" value="admin">
</form>
<button hx-get="/users" hx-include="#filters">Filter</button>
Encoding
Control request encoding:<!-- URL-encoded (default for forms) -->
<form hx-post="/users" hx-encoding="application/x-www-form-urlencoded">
<input name="name">
<button type="submit">Create</button>
</form>
<!-- Multipart (for file uploads) -->
<form hx-post="/upload" hx-encoding="multipart/form-data">
<input type="file" name="file">
<button type="submit">Upload</button>
</form>
Common Patterns
Click to Load
<button hx-get="/data" hx-target="#result">
Load Data
</button>
<div id="result"></div>
Click to Edit
View mode:<div id="user-1">
<span>Alice</span>
<button hx-get="/users/1/edit" hx-target="#user-1">Edit</button>
</div>
<div id="user-1">
<form hx-put="/users/1" hx-target="#user-1">
<input name="name" value="Alice">
<button type="submit">Save</button>
<button hx-get="/users/1" hx-target="#user-1">Cancel</button>
</form>
</div>
Inline Delete
<tr>
<td>Alice</td>
<td>alice@example.com</td>
<td>
<button
hx-delete="/users/1"
hx-target="closest tr"
hx-swap="outerHTML swap:1s"
hx-confirm="Delete Alice?"
>
Delete
</button>
</td>
</tr>
Active Search
<input
type="search"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
hx-indicator="#search-spinner"
placeholder="Search..."
>
<span id="search-spinner" class="htmx-indicator">Searching...</span>
<div id="search-results"></div>
func handleSearch(c *mizu.Ctx) error {
query := c.Query("q")
results := searchUsers(query)
return c.Render("partials/search-results", map[string]any{
"Results": results,
"Query": query,
})
}
Infinite Scroll
<div id="posts">
{{ range .Posts }}
<div class="post">{{ .Title }}</div>
{{ end }}
{{ if .HasMore }}
<div
hx-get="/posts?page={{ .NextPage }}"
hx-trigger="revealed"
hx-target="#posts"
hx-swap="beforeend"
>
<div class="htmx-indicator">Loading more...</div>
</div>
{{ end }}
</div>
func handlePosts(c *mizu.Ctx) error {
page := c.QueryInt("page", 1)
limit := 10
posts, total := getPosts(page, limit)
hasMore := (page * limit) < total
return c.Render("partials/posts", map[string]any{
"Posts": posts,
"HasMore": hasMore,
"NextPage": page + 1,
})
}
Lazy Loading
Load content when it becomes visible:<div
hx-get="/lazy-content"
hx-trigger="intersect once"
hx-swap="outerHTML"
>
<div class="skeleton">Loading...</div>
</div>
Polling
Auto-refresh content:<!-- Poll every 2 seconds -->
<div hx-get="/stats" hx-trigger="every 2s" hx-swap="innerHTML">
Loading stats...
</div>
<!-- Stop polling when visible -->
<div
hx-get="/stats"
hx-trigger="every 2s [document.hidden == false]"
>
Loading stats...
</div>
<!-- Start/stop polling -->
<div id="stats" hx-get="/stats" hx-trigger="load, poll from:#poll-btn">
Stats here
</div>
<button id="poll-btn" hx-trigger="click">Start Polling</button>
Progress Bar
<div hx-get="/job/status" hx-trigger="load, every 1s" hx-swap="outerHTML">
<progress value="0" max="100"></progress>
</div>
func handleJobStatus(c *mizu.Ctx) error {
job := getJob()
if job.Progress >= 100 {
// Job complete, return final content
return c.Render("partials/job-complete", job)
}
// Still in progress, return progress bar
return c.Render("partials/job-progress", job)
}
<div hx-get="/job/status" hx-trigger="every 1s" hx-swap="outerHTML">
<progress value="{{ .Progress }}" max="100"></progress>
<span>{{ .Progress }}% complete</span>
</div>
Bulk Operations
<form id="bulk-form">
<table>
{{ range .Users }}
<tr>
<td><input type="checkbox" name="ids" value="{{ .ID }}"></td>
<td>{{ .Name }}</td>
<td>{{ .Email }}</td>
</tr>
{{ end }}
</table>
<button
hx-delete="/users/bulk"
hx-include="#bulk-form"
hx-confirm="Delete selected users?"
hx-target="body"
>
Delete Selected
</button>
</form>
func handleBulkDelete(c *mizu.Ctx) error {
ids := c.Request().Form["ids"]
for _, id := range ids {
deleteUser(id)
}
// Return updated page
users := getUsers()
return c.Render("pages/users", map[string]any{
"Users": users,
})
}
Dependent Selects
<select
name="country"
hx-get="/states"
hx-target="#state-select"
hx-trigger="change"
>
<option value="">Select Country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
</select>
<div id="state-select">
<select name="state" disabled>
<option>Select country first</option>
</select>
</div>
func handleStates(c *mizu.Ctx) error {
country := c.Query("country")
states := getStatesForCountry(country)
return c.Render("partials/state-select", map[string]any{
"States": states,
})
}
Typeahead / Autocomplete
<div>
<input
type="search"
name="q"
hx-get="/autocomplete"
hx-trigger="keyup changed delay:300ms"
hx-target="#suggestions"
placeholder="Search users..."
>
<div id="suggestions"></div>
</div>
func handleAutocomplete(c *mizu.Ctx) error {
query := c.Query("q")
if query == "" {
return c.HTML(200, "")
}
suggestions := searchUsers(query, 5)
return c.Render("partials/suggestions", map[string]any{
"Suggestions": suggestions,
})
}
{{ if .Suggestions }}
<ul class="suggestions">
{{ range .Suggestions }}
<li>
<a href="/users/{{ .ID }}">{{ .Name }} ({{ .Email }})</a>
</li>
{{ end }}
</ul>
{{ end }}
File Upload with Progress
<form
hx-post="/upload"
hx-encoding="multipart/form-data"
hx-target="#result"
>
<input type="file" name="file">
<button type="submit">Upload</button>
<progress id="upload-progress" value="0" max="100" style="display:none"></progress>
</form>
<div id="result"></div>
<script>
htmx.on('#upload-form', 'htmx:xhr:progress', function(evt) {
const progress = document.getElementById('upload-progress')
progress.style.display = 'block'
progress.setAttribute('value', evt.detail.loaded / evt.detail.total * 100)
})
</script>
Modal Dialogs
<button hx-get="/users/1/delete-confirm" hx-target="#modal">
Delete User
</button>
<div id="modal"></div>
<div class="modal-backdrop" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<h2>Confirm Delete</h2>
<p>Are you sure you want to delete this user?</p>
<button
hx-delete="/users/1"
hx-target="#user-1"
hx-swap="outerHTML"
onclick="document.querySelector('.modal-backdrop').remove()"
>
Yes, Delete
</button>
<button onclick="document.querySelector('.modal-backdrop').remove()">
Cancel
</button>
</div>
</div>
Optimistic UI
Show immediate feedback before server response:<button
hx-post="/like"
hx-swap="outerHTML"
onclick="this.innerHTML='♥ Liked (124)'"
>
♡ Like (123)
</button>
Pagination
<div id="users">
<table>
{{ range .Users }}
<tr><td>{{ .Name }}</td></tr>
{{ end }}
</table>
<nav>
{{ if .HasPrev }}
<button hx-get="/users?page={{ .PrevPage }}" hx-target="#users">
Previous
</button>
{{ end }}
<span>Page {{ .CurrentPage }} of {{ .TotalPages }}</span>
{{ if .HasNext }}
<button hx-get="/users?page={{ .NextPage }}" hx-target="#users">
Next
</button>
{{ end }}
</nav>
</div>
Tabs
<div>
<nav>
<button
hx-get="/tabs/profile"
hx-target="#tab-content"
class="active"
>
Profile
</button>
<button
hx-get="/tabs/settings"
hx-target="#tab-content"
>
Settings
</button>
<button
hx-get="/tabs/activity"
hx-target="#tab-content"
>
Activity
</button>
</nav>
<div id="tab-content" hx-get="/tabs/profile" hx-trigger="load">
Loading...
</div>
</div>
HTMX Extensions
Server-Sent Events (SSE)
Real-time updates from server:<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
<div hx-ext="sse" sse-connect="/events">
<div sse-swap="message" hx-swap="beforeend">
<!-- Messages appear here -->
</div>
</div>
func handleEvents(c *mizu.Ctx) error {
c.Writer().Header().Set("Content-Type", "text/event-stream")
c.Writer().Header().Set("Cache-Control", "no-cache")
c.Writer().Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer().(http.Flusher)
if !ok {
return fmt.Errorf("streaming not supported")
}
for {
select {
case msg := <-messageChan:
fmt.Fprintf(c.Writer(), "event: message\n")
fmt.Fprintf(c.Writer(), "data: <div>%s</div>\n\n", msg)
flusher.Flush()
case <-c.Request().Context().Done():
return nil
}
}
}
WebSockets
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
<div hx-ext="ws" ws-connect="/chat">
<div id="messages"></div>
<form ws-send>
<input name="message">
<button type="submit">Send</button>
</form>
</div>
Preload
Preload content on hover:<script src="https://unpkg.com/htmx.org/dist/ext/preload.js"></script>
<a
href="/users/1"
hx-get="/users/1"
hx-target="#main"
preload="mousedown"
>
View User
</a>
Loading States
<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>
<button hx-get="/slow" data-loading-disable>
<span data-loading-class="hidden">Load Data</span>
<span data-loading-class-remove="hidden" class="hidden">Loading...</span>
</button>
Response Targets
Target based on HTTP status:<script src="https://unpkg.com/htmx.org/dist/ext/response-targets.js"></script>
<form
hx-post="/users"
hx-target="#success"
hx-target-error="#error"
hx-target-4*="#validation-error"
hx-target-5*="#server-error"
>
<input name="email">
<button type="submit">Create</button>
</form>
<div id="success"></div>
<div id="error" class="error"></div>
<div id="validation-error" class="error"></div>
<div id="server-error" class="error"></div>
Class Tools
Manipulate classes:<script src="https://unpkg.com/htmx.org/dist/ext/class-tools.js"></script>
<div
hx-get="/data"
classes="add loading:1s, remove loading:1s"
>
Content
</div>
Complete Application Example
Let’s build a complete Task Manager with Mizu backend and HTMX frontend.Backend Structure
app/server/app.go:
package server
import (
"io/fs"
"github.com/go-mizu/mizu"
"github.com/go-mizu/mizu/view"
"taskmanager/static"
"taskmanager/views"
)
func New(cfg *Config) *mizu.App {
app := mizu.New()
// Initialize view engine
viewsFS, _ := fs.Sub(views.FS, ".")
v := view.New(view.Config{
FS: viewsFS,
Extension: ".html",
DefaultLayout: "default",
Development: cfg.Env == "development",
})
if err := v.Load(); err != nil {
panic("failed to load views: " + err.Error())
}
app.Use(v.Middleware())
// Serve static assets
staticFS, _ := fs.Sub(static.FS, ".")
app.Static("/static", staticFS)
// Initialize services
taskService := NewTaskService()
// Setup routes
setupRoutes(app, taskService)
return app
}
app/server/task_service.go:
package server
import (
"sync"
"time"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"` // pending, in_progress, completed
Priority string `json:"priority"` // low, medium, high
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TaskService struct {
tasks map[int]*Task
nextID int
mu sync.RWMutex
}
func NewTaskService() *TaskService {
return &TaskService{
tasks: make(map[int]*Task),
nextID: 1,
}
}
func (s *TaskService) List(filter string) []*Task {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*Task
for _, task := range s.tasks {
if filter == "" || task.Status == filter {
result = append(result, task)
}
}
return result
}
func (s *TaskService) Get(id int) (*Task, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
task, ok := s.tasks[id]
return task, ok
}
func (s *TaskService) Create(title, description, status, priority string) *Task {
s.mu.Lock()
defer s.mu.Unlock()
task := &Task{
ID: s.nextID,
Title: title,
Description: description,
Status: status,
Priority: priority,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.tasks[s.nextID] = task
s.nextID++
return task
}
func (s *TaskService) Update(id int, title, description, status, priority string) (*Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return nil, false
}
task.Title = title
task.Description = description
task.Status = status
task.Priority = priority
task.UpdatedAt = time.Now()
return task, true
}
func (s *TaskService) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return false
}
delete(s.tasks, id)
return true
}
func (s *TaskService) Stats() map[string]int {
s.mu.RLock()
defer s.mu.RUnlock()
stats := map[string]int{
"total": len(s.tasks),
"pending": 0,
"in_progress": 0,
"completed": 0,
}
for _, task := range s.tasks {
stats[task.Status]++
}
return stats
}
app/server/handlers.go:
package server
import (
"github.com/go-mizu/mizu"
)
type Handlers struct {
taskService *TaskService
}
func NewHandlers(taskService *TaskService) *Handlers {
return &Handlers{
taskService: taskService,
}
}
func (h *Handlers) Home(c *mizu.Ctx) error {
return c.Render("pages/home", map[string]any{
"Title": "Task Manager",
})
}
func (h *Handlers) ListTasks(c *mizu.Ctx) error {
filter := c.Query("status")
tasks := h.taskService.List(filter)
// Check if HTMX request
if c.Request().Header.Get("HX-Request") == "true" {
return c.Render("partials/task-list", map[string]any{
"Tasks": tasks,
"Filter": filter,
})
}
// Full page
return c.Render("pages/tasks", map[string]any{
"Title": "Tasks",
"Tasks": tasks,
"Filter": filter,
})
}
func (h *Handlers) CreateTask(c *mizu.Ctx) error {
title := c.FormValue("title")
description := c.FormValue("description")
status := c.FormValue("status")
priority := c.FormValue("priority")
// Validation
if title == "" {
c.Writer().Header().Set("HX-Retarget", "#error")
c.Writer().Header().Set("HX-Reswap", "innerHTML")
return c.HTML(400, `<div class="error">Title is required</div>`)
}
task := h.taskService.Create(title, description, status, priority)
// Trigger stats update
c.Writer().Header().Set("HX-Trigger", "taskCreated")
return c.Render("partials/task-row", map[string]any{
"Task": task,
})
}
func (h *Handlers) EditTask(c *mizu.Ctx) error {
id := c.ParamInt("id")
task, ok := h.taskService.Get(id)
if !ok {
return c.HTML(404, "Task not found")
}
return c.Render("partials/task-edit", map[string]any{
"Task": task,
})
}
func (h *Handlers) UpdateTask(c *mizu.Ctx) error {
id := c.ParamInt("id")
title := c.FormValue("title")
description := c.FormValue("description")
status := c.FormValue("status")
priority := c.FormValue("priority")
task, ok := h.taskService.Update(id, title, description, status, priority)
if !ok {
return c.HTML(404, "Task not found")
}
// Trigger stats update
c.Writer().Header().Set("HX-Trigger", "taskUpdated")
return c.Render("partials/task-row", map[string]any{
"Task": task,
})
}
func (h *Handlers) DeleteTask(c *mizu.Ctx) error {
id := c.ParamInt("id")
if !h.taskService.Delete(id) {
return c.HTML(404, "Task not found")
}
// Trigger stats update
c.Writer().Header().Set("HX-Trigger", "taskDeleted")
// Return empty response (element will be removed)
return c.HTML(200, "")
}
func (h *Handlers) Stats(c *mizu.Ctx) error {
stats := h.taskService.Stats()
return c.Render("partials/stats", map[string]any{
"Stats": stats,
})
}
app/server/routes.go:
package server
import (
"github.com/go-mizu/mizu"
)
func setupRoutes(app *mizu.App, taskService *TaskService) {
h := NewHandlers(taskService)
// Pages
app.Get("/", h.Home)
app.Get("/tasks", h.ListTasks)
// Task operations
app.Post("/tasks", h.CreateTask)
app.Get("/tasks/{id}/edit", h.EditTask)
app.Put("/tasks/{id}", h.UpdateTask)
app.Delete("/tasks/{id}", h.DeleteTask)
// Stats
app.Get("/stats", h.Stats)
}
Frontend Templates
views/layouts/default.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} - Task Manager</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
<nav class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900">Task Manager</h1>
<div
id="stats"
hx-get="/stats"
hx-trigger="load, taskCreated from:body, taskUpdated from:body, taskDeleted from:body"
class="flex gap-4"
>
Loading stats...
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-8">
{{ embed }}
</main>
</body>
</html>
views/pages/home.html:
<div class="space-y-6">
<!-- Create Task Form -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Create New Task</h2>
<form
hx-post="/tasks"
hx-target="#task-list tbody"
hx-swap="afterbegin"
hx-on::after-request="this.reset()"
class="space-y-4"
>
<div id="error" class="text-red-600"></div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
name="title"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Task title"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
name="description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Task description"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
name="priority"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Create Task
</button>
</form>
</div>
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex gap-2">
<button
hx-get="/tasks"
hx-target="#task-list-container"
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
>
All
</button>
<button
hx-get="/tasks?status=pending"
hx-target="#task-list-container"
class="px-4 py-2 rounded bg-yellow-200 hover:bg-yellow-300"
>
Pending
</button>
<button
hx-get="/tasks?status=in_progress"
hx-target="#task-list-container"
class="px-4 py-2 rounded bg-blue-200 hover:bg-blue-300"
>
In Progress
</button>
<button
hx-get="/tasks?status=completed"
hx-target="#task-list-container"
class="px-4 py-2 rounded bg-green-200 hover:bg-green-300"
>
Completed
</button>
</div>
</div>
<!-- Task List -->
<div id="task-list-container" hx-get="/tasks" hx-trigger="load">
<div class="bg-white rounded-lg shadow p-6">
<div class="htmx-indicator">Loading tasks...</div>
</div>
</div>
</div>
views/partials/task-list.html:
<div class="bg-white rounded-lg shadow">
<table id="task-list" class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Title
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Priority
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{ range .Tasks }}
{{ template "partials/task-row" (dict "Task" .) }}
{{ else }}
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500">
No tasks found
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
views/partials/task-row.html:
<tr id="task-{{ .Task.ID }}">
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">
{{ .Task.Title }}
</div>
{{ if .Task.Description }}
<div class="text-sm text-gray-500">
{{ .Task.Description }}
</div>
{{ end }}
</td>
<td class="px-6 py-4">
{{ if eq .Task.Status "pending" }}
<span class="px-2 py-1 text-xs font-semibold rounded bg-yellow-100 text-yellow-800">
Pending
</span>
{{ else if eq .Task.Status "in_progress" }}
<span class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 text-blue-800">
In Progress
</span>
{{ else }}
<span class="px-2 py-1 text-xs font-semibold rounded bg-green-100 text-green-800">
Completed
</span>
{{ end }}
</td>
<td class="px-6 py-4">
{{ if eq .Task.Priority "low" }}
<span class="text-sm text-gray-600">Low</span>
{{ else if eq .Task.Priority "medium" }}
<span class="text-sm text-blue-600">Medium</span>
{{ else }}
<span class="text-sm text-red-600">High</span>
{{ end }}
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<button
hx-get="/tasks/{{ .Task.ID }}/edit"
hx-target="#task-{{ .Task.ID }}"
hx-swap="outerHTML"
class="text-blue-600 hover:text-blue-800"
>
Edit
</button>
<button
hx-delete="/tasks/{{ .Task.ID }}"
hx-target="#task-{{ .Task.ID }}"
hx-swap="outerHTML swap:1s"
hx-confirm="Are you sure you want to delete this task?"
class="text-red-600 hover:text-red-800"
>
Delete
</button>
</div>
</td>
</tr>
views/partials/task-edit.html:
<tr id="task-{{ .Task.ID }}">
<td colspan="4" class="px-6 py-4">
<form
hx-put="/tasks/{{ .Task.ID }}"
hx-target="#task-{{ .Task.ID }}"
hx-swap="outerHTML"
class="space-y-4"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
name="title"
value="{{ .Task.Title }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
name="description"
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>{{ .Task.Description }}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="pending" {{ if eq .Task.Status "pending" }}selected{{ end }}>
Pending
</option>
<option value="in_progress" {{ if eq .Task.Status "in_progress" }}selected{{ end }}>
In Progress
</option>
<option value="completed" {{ if eq .Task.Status "completed" }}selected{{ end }}>
Completed
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
name="priority"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="low" {{ if eq .Task.Priority "low" }}selected{{ end }}>
Low
</option>
<option value="medium" {{ if eq .Task.Priority "medium" }}selected{{ end }}>
Medium
</option>
<option value="high" {{ if eq .Task.Priority "high" }}selected{{ end }}>
High
</option>
</select>
</div>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
type="button"
hx-get="/tasks/{{ .Task.ID }}"
hx-target="#task-{{ .Task.ID }}"
hx-swap="outerHTML"
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</td>
</tr>
func (h *Handlers) GetTask(c *mizu.Ctx) error {
id := c.ParamInt("id")
task, ok := h.taskService.Get(id)
if !ok {
return c.HTML(404, "Task not found")
}
return c.Render("partials/task-row", map[string]any{
"Task": task,
})
}
app.Get("/tasks/{id}", h.GetTask)
views/partials/stats.html:
<div class="flex gap-4 text-sm">
<div class="flex items-center gap-2">
<span class="text-gray-600">Total:</span>
<span class="font-semibold">{{ .Stats.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-yellow-400"></span>
<span>{{ .Stats.pending }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-blue-400"></span>
<span>{{ .Stats.in_progress }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-green-400"></span>
<span>{{ .Stats.completed }}</span>
</div>
</div>
Error Handling
Client-Side Errors
<form hx-post="/users">
<input name="email" type="email" required>
<button type="submit">Create</button>
</form>
Server-Side Errors
Return appropriate HTTP status codes:func handleCreate(c *mizu.Ctx) error {
email := c.FormValue("email")
// Validation error (400)
if !isValidEmail(email) {
return c.HTML(400, `<div class="error">Invalid email</div>`)
}
// Not found (404)
if !userExists(email) {
return c.HTML(404, `<div class="error">User not found</div>`)
}
// Server error (500)
if err := createUser(email); err != nil {
return c.HTML(500, `<div class="error">Server error</div>`)
}
// Success (200)
return c.Render("partials/user", user)
}
Global Error Handling
<script>
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('Request failed:', evt.detail.xhr.status)
alert('Something went wrong. Please try again.')
})
document.body.addEventListener('htmx:sendError', function(evt) {
console.error('Network error:', evt.detail.error)
alert('Network error. Please check your connection.')
})
</script>
Security
CSRF Protection
Use CSRF middleware:import "github.com/go-mizu/mizu/middlewares/csrf"
app.Use(csrf.New())
<form hx-post="/users">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input name="email">
<button type="submit">Create</button>
</form>
XSS Protection
Always escape user content in templates. Go’shtml/template does this automatically:
<!-- Safe: automatically escaped -->
<div>{{ .UserInput }}</div>
<!-- Unsafe: skip this unless you control the content -->
<div>{{ .TrustedHTML | safe }}</div>
SQL Injection
Use parameterized queries:// Good: parameterized
db.Query("SELECT * FROM users WHERE email = ?", email)
// Bad: string concatenation
db.Query("SELECT * FROM users WHERE email = '" + email + "'")
Rate Limiting
import "github.com/go-mizu/mizu/middlewares/ratelimit"
app.Use(ratelimit.New(ratelimit.Config{
Max: 100,
Duration: time.Minute,
}))
Performance
Caching
Cache responses:import "github.com/go-mizu/mizu/middlewares/cache"
app.Use(cache.New(cache.Config{
TTL: 5 * time.Minute,
}))
func handler(c *mizu.Ctx) error {
c.Writer().Header().Set("Cache-Control", "max-age=300")
return c.Render("partials/data", data)
}
Compression
import "github.com/go-mizu/mizu/middlewares/compress"
app.Use(compress.New())
Lazy Loading
Load content only when needed:<div hx-get="/heavy-content" hx-trigger="intersect once">
Loading...
</div>
Debouncing
Reduce server requests:<input
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
>
Testing
Backend Tests
func TestHandlers(t *testing.T) {
app := setupTestApp()
// Test GET
req := httptest.NewRequest("GET", "/tasks", nil)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
assert.Equal(t, 200, rec.Code)
assert.Contains(t, rec.Body.String(), "task-list")
// Test POST
form := url.Values{}
form.Add("title", "Test Task")
form.Add("status", "pending")
req = httptest.NewRequest("POST", "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
rec = httptest.NewRecorder()
app.ServeHTTP(rec, req)
assert.Equal(t, 200, rec.Code)
assert.Contains(t, rec.Body.String(), "Test Task")
}
Frontend Tests
Use tools like Playwright or Cypress:test('create task', async ({ page }) => {
await page.goto('http://localhost:3000')
await page.fill('[name="title"]', 'Test Task')
await page.selectOption('[name="status"]', 'pending')
await page.click('button[type="submit"]')
// Wait for HTMX to complete
await page.waitForSelector('text=Test Task')
expect(await page.textContent('tbody')).toContain('Test Task')
})
Troubleshooting
HTMX Not Working
Check:- HTMX library is loaded:
<script src="https://unpkg.com/htmx.org@1.9.10"></script> - Check browser console for errors
- Verify server is returning HTML (not JSON)
- Check
HX-Requestheader is sent
Content Not Updating
Check:hx-targetpoints to existing elementhx-swapstrategy is correct- Server response contains expected HTML
- No JavaScript errors preventing swap
Form Not Submitting
Check:- Form has
hx-postor similar attribute - Input fields have
nameattributes - Server endpoint exists and accepts POST
- No validation errors preventing submit
History Not Working
Check:- Using
hx-push-urlorhx-replace-url - Handling history restore requests
- URLs are valid
Debug Mode
Enable HTMX logging:<script>
htmx.logAll()
</script>
Combining with Alpine.js
Alpine.js adds client-side reactivity to complement HTMX:<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<div x-data="{ open: false }">
<!-- Alpine for UI state -->
<button @click="open = !open">Toggle</button>
<div x-show="open" x-transition>
<!-- HTMX for data fetching -->
<div hx-get="/data" hx-trigger="revealed once" hx-swap="innerHTML">
Loading...
</div>
</div>
</div>
<div x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="btn">
Actions
</button>
<div x-show="open" x-transition class="dropdown">
<a href="#" hx-get="/edit" hx-target="#main">Edit</a>
<a href="#" hx-delete="/delete" hx-confirm="Delete?">Delete</a>
</div>
</div>
When to Use HTMX
Perfect For
- CRUD Applications: Forms, lists, updates
- Admin Dashboards: Data tables, filters, actions
- Content Sites: Blogs, documentation, marketing
- Progressive Enhancement: Start with plain HTML, add interactivity
- Server-Side Teams: Developers comfortable with backend rendering
- SEO-Critical Apps: Need server-rendered HTML
- Rapid Prototyping: Quick feedback loop
Not Ideal For
- Desktop-Like Apps: Complex client-side state
- Real-Time Collaboration: Multiple users editing simultaneously
- Offline-First: Apps that work without network
- Heavy Client Logic: Complex calculations or data processing
- Mobile Apps: Need native feel with offline support
Hybrid Approach
Use HTMX for most of the app, add JavaScript where needed:<!-- HTMX for CRUD -->
<div hx-get="/users" hx-target="#list">Load Users</div>
<!-- JavaScript for rich interactions -->
<canvas id="chart"></canvas>
<script>
renderChart(document.getElementById('chart'), data)
</script>
Next Steps
Alpine.js
Add client-side reactivity with Alpine.js
View Engine
Learn more about Mizu’s template engine
React Guide
Compare with SPA approach
HTMX Docs
Official HTMX documentation
HTMX Examples
More patterns and examples
Hypermedia Systems
Book on hypermedia-driven applications