cicadel

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

Text
Read https://cicadel.fauzaanu.com/en/0.2.7/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:

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 at htmx.ScriptPath.
  • htmx.HTML takes two renderings: the full page and the results fragment. 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:

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:

Go
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

Terminal
go tool cicadel ui build
go tool cicadel ui check --json
go test -race ./...
go run manage.go runserver

Open http://127.0.0.1:8000/search. You can also check both response forms:

Terminal
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'
RequestResponse
OrdinaryA full HTML document.
htmx partialThe <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:

templ
@layout.Page.Call(layout.Props{Title: "Notes", CSRFToken: token}, layout.Slots{
    Scripts:  htmx.Script(),
    Children: ui.Fragment(notes(items)),
})
  • token comes from your session, for example browser.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:

templ
<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:

FunctionHeaderEffect
Trigger(w, "saved")HX-TriggerDispatch events on the requesting element.
TriggerDetail(w, "notify", detail)HX-TriggerAn event with a JSON detail. Calls accumulate.
Redirect(w, "/notes/")HX-RedirectFull-page navigation. Writes status 200.
Location(w, "/notes/")HX-Locationhtmx navigation without a full reload. Writes 200.
Refresh(w)HX-RefreshReload the page.
Retarget(w, "#errors")HX-RetargetSwap into another element.
Reswap(w, "outerHTML")HX-ReswapChange the swap style.
Reselect(w, "#list")HX-ReselectSwap only part of the response.
PushURL(w, "/notes/?page=2")HX-Push-UrlPush a history entry.
ReplaceURL(w, "/notes/")HX-Replace-UrlReplace 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.

Go
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
templ counter(n int) {
    <span id="note-count" hx-swap-oob="true">{ strconv.Itoa(n) }</span>
}
Go
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

NeedUse
Inherit an attributeExplicit inheritance, such as hx-target:inherited="#results"
Queue requestshx-sync="this:queue all"
Keep content on a server errorhx-status:5xx="swap:none"

Return useful field errors with status 422.

Type to search this version.