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:Copy
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:Copy
Client Request → JSON Response → JavaScript Renders UI
Copy
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
Copy
<script src="https://unpkg.com/[email protected]"></script>
Copy
<script src="https://unpkg.com/[email protected]/dist/htmx.min.js"></script>
Via npm
Copy
npm install htmx.org
Copy
import 'htmx.org'
Copy
import htmx from 'htmx.org'
window.htmx = htmx
Architecture
Development Mode
Copy
┌─────────────────────────────────────────────────────────────┐
│ 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
Copy
┌─────────────────────────────────────────────────────────────┐
│ 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
Copy
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
Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
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:Copy
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:Copy
<!-- 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:Copy
<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>
Copy
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)
}
Copy
<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:Copy
<!-- 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>
Copy
<!-- 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>
Copy
<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:Copy
<!-- 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>
Copy
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:Copy
<!-- 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:Copy
<!-- 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>
Copy
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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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:Copy
<!-- 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
Copy
<button hx-get="/data" hx-target="#result">
Load Data
</button>
<div id="result"></div>
Click to Edit
View mode:Copy
<div id="user-1">
<span>Alice</span>
<button hx-get="/users/1/edit" hx-target="#user-1">Edit</button>
</div>
Copy
<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
Copy
<tr>
<td>Alice</td>
<td>[email protected]</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
Copy
<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>
Copy
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
Copy
<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>
Copy
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:Copy
<div
hx-get="/lazy-content"
hx-trigger="intersect once"
hx-swap="outerHTML"
>
<div class="skeleton">Loading...</div>
</div>
Polling
Auto-refresh content:Copy
<!-- 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
Copy
<div hx-get="/job/status" hx-trigger="load, every 1s" hx-swap="outerHTML">
<progress value="0" max="100"></progress>
</div>
Copy
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)
}
Copy
<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
Copy
<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>
Copy
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
Copy
<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>
Copy
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
Copy
<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>
Copy
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,
})
}
Copy
{{ if .Suggestions }}
<ul class="suggestions">
{{ range .Suggestions }}
<li>
<a href="/users/{{ .ID }}">{{ .Name }} ({{ .Email }})</a>
</li>
{{ end }}
</ul>
{{ end }}
File Upload with Progress
Copy
<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
Copy
<button hx-get="/users/1/delete-confirm" hx-target="#modal">
Delete User
</button>
<div id="modal"></div>
Copy
<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:Copy
<button
hx-post="/like"
hx-swap="outerHTML"
onclick="this.innerHTML='♥ Liked (124)'"
>
♡ Like (123)
</button>
Pagination
Copy
<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
Copy
<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:Copy
<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>
Copy
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
Copy
<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:Copy
<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
Copy
<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:Copy
<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:Copy
<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:
Copy
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:
Copy
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:
Copy
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:
Copy
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:
Copy
<!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/[email protected]"></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:
Copy
<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:
Copy
<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:
Copy
<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:
Copy
<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>
Copy
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,
})
}
Copy
app.Get("/tasks/{id}", h.GetTask)
views/partials/stats.html:
Copy
<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
Copy
<form hx-post="/users">
<input name="email" type="email" required>
<button type="submit">Create</button>
</form>
Server-Side Errors
Return appropriate HTTP status codes:Copy
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
Copy
<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:Copy
import "github.com/go-mizu/mizu/middlewares/csrf"
app.Use(csrf.New())
Copy
<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:
Copy
<!-- Safe: automatically escaped -->
<div>{{ .UserInput }}</div>
<!-- Unsafe: skip this unless you control the content -->
<div>{{ .TrustedHTML | safe }}</div>
SQL Injection
Use parameterized queries:Copy
// Good: parameterized
db.Query("SELECT * FROM users WHERE email = ?", email)
// Bad: string concatenation
db.Query("SELECT * FROM users WHERE email = '" + email + "'")
Rate Limiting
Copy
import "github.com/go-mizu/mizu/middlewares/ratelimit"
app.Use(ratelimit.New(ratelimit.Config{
Max: 100,
Duration: time.Minute,
}))
Performance
Caching
Cache responses:Copy
import "github.com/go-mizu/mizu/middlewares/cache"
app.Use(cache.New(cache.Config{
TTL: 5 * time.Minute,
}))
Copy
func handler(c *mizu.Ctx) error {
c.Writer().Header().Set("Cache-Control", "max-age=300")
return c.Render("partials/data", data)
}
Compression
Copy
import "github.com/go-mizu/mizu/middlewares/compress"
app.Use(compress.New())
Lazy Loading
Load content only when needed:Copy
<div hx-get="/heavy-content" hx-trigger="intersect once">
Loading...
</div>
Debouncing
Reduce server requests:Copy
<input
hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
>
Testing
Backend Tests
Copy
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:Copy
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/[email protected]"></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:Copy
<script>
htmx.logAll()
</script>
Combining with Alpine.js
Alpine.js adds client-side reactivity to complement HTMX:Copy
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/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>
Copy
<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:Copy
<!-- 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>