Skip to content

htmx

Serves the htmx JavaScript library as a static asset and provides Go helpers for htmx request detection and response headers. Inspired by django-htmx.

Package: github.com/oliverandrich/burrow/contrib/htmx

Depends on: staticfiles

Setup

srv := burrow.NewServer(
    htmx.New(),
    staticApp, // staticfiles.New(myStaticFS) — returns (*App, error)
    // ... other apps
)

The htmx app embeds htmx.min.js and the SSE extension (ext/sse.min.js), serving both via the staticfiles app under the "htmx" prefix. It also provides a htmx/config template with sensible defaults. Include both in your layout template:

{{ template "htmx/js" . }}
{{ template "htmx/config" . }}

The htmx/config template renders a <meta> tag that configures htmx to swap 422 Unprocessable Entity responses. This is the correct HTTP status for form validation errors, and allows handlers to return 422 consistently for both htmx and non-htmx requests.

Include {{ template "htmx/config" . }} in your layout <head> next to {{ template "htmx/js" . }}.

Templates

The htmx app implements HasTemplates and contributes these templates:

Template Description
htmx/js <script defer> tag for htmx JS
htmx/config <meta> tag with htmx response handling config (swaps 422 responses)
htmx/dialog_script Client-side listeners for htmx.OpenDialog / htmx.CloseDialog server triggers, plus delegated close on [rel="prev"] clicks.

HTMX-driven dialogs

A standard pattern for opening forms (or any other content) inside a <dialog> element via HTMX. The server returns content swapped into a permanent modal container plus a trigger header; the client opens or closes the dialog accordingly.

Setup

Add a permanent empty <dialog id="modal"></dialog> container plus the htmx/dialog_script listener to your layout:

<dialog id="modal"></dialog>
{{ template "htmx/dialog_script" . }}

The dialog is a structural container only — each view that opens the dialog renders its own content (an <article>, a <form>, or any other element) as the swapped content. This lets the view choose its own width, classes, or bypass any card chrome entirely.

Trigger buttons

Point the trigger at #modal with innerHTML swap. The handler returns the article element, the server sets the open header:

<a href="/notes/new" role="button"
   hx-get="/notes/new"
   hx-target="#modal"
   hx-swap="innerHTML">
    Add note
</a>
{{ define "notes/form" -}}
<article>
    <header>
        <button aria-label="Close" rel="prev"></button>
        <strong>Add note</strong>
    </header>
    <form ... hx-target="#modal" hx-swap="innerHTML">...</form>
</article>
{{- end }}
func (a *App) New(w http.ResponseWriter, r *http.Request) error {
    // ... build form data ...
    htmx.OpenDialog(w, "modal", "modal-lg") // optional class on the dialog
    return burrow.Render(w, r, http.StatusOK, "notes/form", data)
}

The optional third argument to OpenDialog replaces the dialog element's className before opening — useful for size or styling variants defined in your stylesheet that target the dialog itself rather than its content. Pass "" to clear a previously applied class.

Validation errors

Forms inside the dialog post back to #modal. On a validation error, the handler returns the form (wrapped in its <article>) with errors filled in — content swaps in place, dialog stays open:

func (a *App) Create(w http.ResponseWriter, r *http.Request) error {
    f := forms.New[Note]()
    if !f.Bind(r) {
        // No OpenDialog needed — dialog is already open.
        return burrow.Render(w, r, http.StatusUnprocessableEntity, "notes/form", errorData)
    }
    // ... persist ...
    htmx.CloseDialog(w, "modal")
    return htmx.RenderOrRedirect(w, r, "/notes", "notes/create_response", successData)
}

Closing

The server tells the client to close via htmx.CloseDialog(w, id). Users can also close manually by clicking any element with rel="prev" or data-close-dialog inside the dialog (works without server roundtrip):

<header>
    <button aria-label="Close" rel="prev"></button>
    <strong>Edit note</strong>
</header>

<footer class="form-actions">
    <button type="submit">Save</button>
    <button type="button" class="secondary outline" data-close-dialog>Cancel</button>
</footer>

Why two attributes? rel="prev" carries the visual side-effect that some CSS frameworks style as a close-icon button (the rel="prev" selector is a long-standing convention). data-close-dialog is purely behavioral — use it on cancel buttons, "Not now" links, or any other element that should close the dialog without inheriting close-icon styling.

How it works

htmx.OpenDialog(w, id) sets HX-Trigger-After-Swap: {"openDialog":"<id>"}. After the swap completes, htmx fires an openDialog event on <body> with event.detail.value === "<id>". The htmx/dialog_script listener finds the matching <dialog> and calls showModal(). CloseDialog is the symmetric mirror with closeDialog.

The script also installs a delegated click listener: any element with rel="prev" inside an open dialog closes that dialog client-side (no server roundtrip). This intentionally swallows the click — including links — so place <a rel="prev" href="../..."> inside an open dialog only if you mean "close the dialog, do not navigate." Outside dialogs the listener does nothing.

Multiple dialogs on one page

The default nav_layout ships a single <dialog id="modal"> container, which is why the docs use htmx.OpenDialog(w, "modal") everywhere. For a second dialog on the same page, render an additional <dialog id="other-modal"> element somewhere in your layout (or an OOB swap) and pass that id to the helpers:

htmx.OpenDialog(w, "confirm-delete")
htmx.CloseDialog(w, "confirm-delete")

Header composition

HX-Trigger, HX-Trigger-After-Swap, and HX-Trigger-After-Settle are single-slot headers. htmx.OpenDialog / htmx.CloseDialog both write HX-Trigger-After-Swap, as does htmx.TriggerAfterSwap. A handler that calls more than one of these for the same response will only ship the last value. If you need to fire multiple after-swap events, build the JSON object yourself with w.Header().Set("HX-Trigger-After-Swap", ...) containing all events.

Request Detection

Parse htmx-specific request headers with htmx.Request():

import "github.com/oliverandrich/burrow/contrib/htmx"

func (a *App) List(w http.ResponseWriter, r *http.Request) error {
    hx := htmx.Request(r)

    if hx.IsHTMX() {
        // Partial response — just the fragment
        return burrow.Render(w, r, http.StatusOK, "notes/list-fragment", data)
    }

    // Full page response
    return burrow.Render(w, r, http.StatusOK, "notes/list", data)
}

Automatic layout detection

burrow.Render() already skips layout wrapping when it detects an HX-Request header. You typically don't need to check hx.IsHTMX() manually unless you want to return completely different content for htmx requests.

Available Request Methods

Method Header Description
IsHTMX() HX-Request Request was made by htmx
IsBoosted() HX-Boosted Request is via an hx-boost element
Target() HX-Target ID of the target element
Trigger() HX-Trigger ID of the triggered element
TriggerName() HX-Trigger-Name Name of the triggered element
Prompt() HX-Prompt User response to hx-prompt
CurrentURL() HX-Current-URL Current browser URL
HistoryRestore() HX-History-Restore-Request History restoration after cache miss

Response Helpers

Smart Helpers

These helpers handle the common pattern of branching between htmx and non-htmx requests:

SmartRedirect

Issues an HX-Redirect for htmx requests or a standard 303 redirect for normal requests.

// Before:
if htmx.Request(r).IsHTMX() {
    htmx.Redirect(w, "/dashboard")
    w.WriteHeader(http.StatusOK)
    return nil
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return nil

// After:
htmx.SmartRedirect(w, r, "/dashboard")
return nil

RenderOrRedirect

Renders a template fragment for htmx requests, or issues a 303 redirect for standard requests.

// Before:
if htmx.Request(r).IsHTMX() {
    return burrow.Render(w, r, http.StatusOK, "notes/create_response", data)
}
http.Redirect(w, r, "/notes", http.StatusSeeOther)
return nil

// After:
return htmx.RenderOrRedirect(w, r, "/notes", "notes/create_response", data)

Header Setters

Set htmx response headers to control client-side behaviour:

import "github.com/oliverandrich/burrow/contrib/htmx"

func (a *App) Delete(w http.ResponseWriter, r *http.Request) error {
    // ... delete resource ...

    // Redirect the browser (client-side, no full page reload)
    htmx.Redirect(w, "/notes")
    return nil
}
Function Header Description
Redirect(w, url) HX-Redirect Client-side redirect
Refresh(w) HX-Refresh Full page refresh
Trigger(w, event) HX-Trigger Trigger a client-side event
TriggerAfterSettle(w, event) HX-Trigger-After-Settle Trigger event after settle
TriggerAfterSwap(w, event) HX-Trigger-After-Swap Trigger event after swap
PushURL(w, url) HX-Push-Url Push URL to history stack
ReplaceURL(w, url) HX-Replace-Url Replace current URL
Reswap(w, strategy) HX-Reswap Override swap strategy
Retarget(w, selector) HX-Retarget Change target element
Reselect(w, selector) HX-Reselect Change content selection
Location(w, url) HX-Location Navigate without full reload

Constants

Constant Value Description
StatusStopPolling 286 HTTP status code that instructs htmx to stop polling

Interfaces Implemented

Interface Description
burrow.App Required: Name()
HasStaticFiles Contributes embedded htmx.min.js under "htmx" prefix
HasTemplates Contributes htmx/js and htmx/config templates
HasDependencies Requires staticfiles