Add interactive behavior
Use htmx to update part of a page with server-rendered HTML. This guide builds a search form that works with htmx and with ordinary navigation.
Cicadel bundles htmx 4. Your Go application serves the script, so you need no CDN.
#Ask your agent
Read https://cicadel.fauzaanu.com/en/dev/howto/htmx/index.md.
Add a GET search form to my application using Cicadel's bundled htmx.
Register the script route, include the script once, and render a full page for
ordinary requests and a results fragment for partial requests.
Keep authorization in the handler and test both responses, including escaped input.#1. Register the script and search routes
Add apps/search/app.go:
package search
import (
"log/slog"
"net/http"
"strings"
"github.com/fauzaanu/cicadel"
"github.com/fauzaanu/cicadel/htmx"
"github.com/fauzaanu/cicadel/response"
"github.com/fauzaanu/cicadel/ui"
"github.com/fauzaanu/cicadel/urls"
)
func New() cicadel.App {
return cicadel.App{
Name: "search",
Routes: []urls.Route{
urls.Path("GET", htmx.ScriptPath, "search:script", htmx.Handler().ServeHTTP),
urls.Path("GET", "/search", "search:index", Search),
},
}
}
func Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
items := []string{}
for _, title := range []string{"Go notes", "PostgreSQL notes", "UI notes"} {
if strings.Contains(strings.ToLower(title), strings.ToLower(query)) {
items = append(items, title)
}
}
err := htmx.HTML(w, r, http.StatusOK,
ui.Fragment(page(query, items)),
ui.Fragment(results(items)),
)
if err != nil {
slog.ErrorContext(r.Context(), "render search", "error", err)
if !ui.Committed(err) {
response.Error(w, http.StatusInternalServerError)
}
}
}htmx.Handler()serves the bundled script athtmx.ScriptPath.htmx.HTMLtakes two renderings: the fullpageand theresultsfragment. It sends the fragment for htmx partial requests and the full page otherwise.
For database-backed search, replace the sample list with a query scoped to the current user’s permitted rows. Use the same permissions for full and partial requests.
#2. Include the script and render results
Add apps/search/page.templ:
package search
import "github.com/fauzaanu/cicadel/htmx"
import "github.com/fauzaanu/cicadel/ui"
import "github.com/fauzaanu/cicadel/ui/layout"
templ page(query string, items []string) {
@layout.Page.Call(layout.Props{Title: "Search"}, layout.Slots{
Scripts: htmx.Script(),
Children: ui.Fragment(searchForm(query, items)),
})
}
templ searchForm(query string, items []string) {
<h1>Search notes</h1>
<form action="/search" method="get" hx-get="/search" hx-target="#results">
<label for="query">Search</label>
<input id="query" name="q" type="search" value={ query }/>
<button type="submit">Search</button>
</form>
<section id="results" aria-live="polite">
@results(items)
</section>
}
templ results(items []string) {
if len(items) == 0 {
<p>No results.</p>
}
<ul>
for _, item := range items {
<li>{ item }</li>
}
</ul>
}Scripts: htmx.Script()includes htmx once, in the full document.- The partial response contains only the results markup.
- Text inside
{ ... }is escaped.
#3. Register the app
In project/apps.go, import example.com/myapp/apps/search and add search.New()
to the existing list:
func InstalledApps(_ *gorm.DB) ([]cicadel.App, error) {
return []cicadel.App{
notes.New(),
search.New(),
}, nil
}Keep your other app registrations. Use your own module path in the import.
#4. Build and run
go tool cicadel ui build
go tool cicadel ui check --json
go test -race ./...
go run manage.go runserverOpen http://127.0.0.1:8000/search. You can also check both response forms:
curl 'http://127.0.0.1:8000/search?q=Go'
curl -H 'HX-Request: true' -H 'HX-Request-Type: partial' \
'http://127.0.0.1:8000/search?q=Go'| Request | Response |
|---|---|
| Ordinary | A full HTML document. |
| htmx partial | The <ul> results, with no document wrapper. |
htmx.HTML also sets the required Vary headers. It returns a full page for
history restoration.
#POST and DELETE requests
The example uses a read-only GET. For writes, keep your app’s authentication and CSRF middleware, and send its CSRF token.
#CSRF for htmx requests
Pass the session’s token to the page layout. Every htmx request from the page then
sends it as the X-CSRF-Token header, which security.Sessions.CSRF accepts:
@layout.Page.Call(layout.Props{Title: "Notes", CSRFToken: token}, layout.Slots{
Scripts: htmx.Script(),
Children: ui.Fragment(notes(items)),
})tokencomes from your session, for examplebrowser.CSRFToken(r.Context()).- The layout renders
<body hx-headers:inherited='{"X-CSRF-Token":"…"}'>.htmx.CSRFHeaders(token)returns the same value for your own layouts. - Pages that set the token must not be stored by shared caches. The CSRF
middleware already sends
Cache-Control: no-store.
Ordinary forms that must also work without JavaScript still need the hidden field:
<form action="/notes/delete" method="post">
@form.CSRF.Call(form.CSRFProps{Token: token}, ui.NoSlots{})
<button hx-delete="/notes/42" hx-target="#notes">Delete note</button>
</form>form is github.com/fauzaanu/cicadel/ui/form.
Warning
htmx headers are not authorization. Check object permissions before deleting.
#Respond with htmx headers
The htmx package sets the response headers htmx 4 reads:
| Function | Header | Effect |
|---|---|---|
Trigger(w, "saved") | HX-Trigger | Dispatch events on the requesting element. |
TriggerDetail(w, "notify", detail) | HX-Trigger | An event with a JSON detail. Calls accumulate. |
Redirect(w, "/notes/") | HX-Redirect | Full-page navigation. Writes status 200. |
Location(w, "/notes/") | HX-Location | htmx navigation without a full reload. Writes 200. |
Refresh(w) | HX-Refresh | Reload the page. |
Retarget(w, "#errors") | HX-Retarget | Swap into another element. |
Reswap(w, "outerHTML") | HX-Reswap | Change the swap style. |
Reselect(w, "#list") | HX-Reselect | Swap only part of the response. |
PushURL(w, "/notes/?page=2") | HX-Push-Url | Push a history entry. |
ReplaceURL(w, "/notes/") | HX-Replace-Url | Replace the current history entry. |
Redirect, Location, PushURL and ReplaceURL accept only local paths and
return an error otherwise, so a ?next= value cannot redirect off-site. Use
Redirect instead of http.Redirect for htmx requests: the browser follows a
3xx itself, and htmx would swap the target page into the fragment.
func create(w http.ResponseWriter, r *http.Request) {
// ... validate and save ...
if htmx.IsRequest(r) {
_ = htmx.TriggerDetail(w, "notify", map[string]string{"message": "Saved"})
_ = htmx.Redirect(w, "/notes/")
return
}
http.Redirect(w, r, "/notes/", http.StatusSeeOther)
}#Out-of-band swaps
Update other parts of the page in the same response. Group the main fragment with
elements that carry hx-swap-oob and a matching id:
templ counter(n int) {
<span id="note-count" hx-swap-oob="true">{ strconv.Itoa(n) }</span>
}err := htmx.HTML(w, r, http.StatusOK, page, ui.Group(ui.Fragment(row(note)), ui.Fragment(counter(total))))#Errors
With error pages installed, a failed htmx partial request keeps
its status, swaps nothing (HX-Reswap: none) and triggers cicadel:error with
the status and title.
#htmx 4 attributes
| Need | Use |
|---|---|
| Inherit an attribute | Explicit inheritance, such as hx-target:inherited="#results" |
| Queue requests | hx-sync="this:queue all" |
| Keep content on a server error | hx-status:5xx="swap:none" |
Return useful field errors with status 422.