thx

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 27 Imported by: 0

README


thx
thx

optimizing the handling of templ and htmx within go applications


Go v1.25   Go-Templ v0.3.1001   HTMX v4.0.0-beta6  

tests   codecov   Go Reference   Go Report Card  

About

This library is a companion to the already awesome libraries go-templ and htmx. It is the glue between those layers, making writing typesafe SSR-based web controllers a joy.

Why thx?

You can wire up templ handlers on http.ServeMux yourself — it works. But you'll end up rebuilding the same glue in every project: checking HX-Request headers to decide between full pages and partials, manually stripping layouts, decoding form data, composing middleware, wiring auth guards, setting Vary headers for caching correctness, handling redirects differently for HTMX vs regular requests.

thx handles all of that so your handlers stay focused on business logic:

  • Type-safe routing — generic [Q, I] parameters decode query strings, form data, and JSON bodies automatically
  • Automatic partial rendering — HTMX requests skip layouts, boosted requests get full pages, Vary headers are set correctly
  • Layout composition — nest layouts declaratively with WithLayout, no manual wrapping
  • HTMX integration — full request/response header coverage via ctx.HTMX() with fluent swap and trigger builders
  • Auth guards — composable authorization rules on route groups and single routes, with 401/403 or redirect responses
  • OOB swaps — update multiple page sections in a single response with SwapOOB
  • SSE and WebSocket — first-class route types with HTMX 4.0 envelope support
  • Result types — explicit Render, JSON, Raw, Status().Empty() — no silent behavior
  • Zero external router — built on Go's http.ServeMux (Go 1.24+), no framework lock-in

Getting started

go get github.com/go-thx/thx

Requires Go 1.24+ and templ.

Minimal example

package main

import (
    "net/http"

    "github.com/go-thx/thx"
)

func main() {
    handler := thx.New(
        thx.Get("/", getIndex),
    )

    http.ListenAndServe(":8080", handler)
}

func getIndex(ctx thx.Context, _ struct{}) thx.Result {
    return thx.Render(ctx, indexPage())
}
templ indexPage() {
    <h1>Hello from thx!</h1>
}

Layouts, forms, and HTMX

func main() {
    handler := thx.New(
        thx.WithLayout(baseLayout,
            thx.Get("/", getIndex),
            thx.Post("/greet", postGreet),
        ),
    )

    http.ListenAndServe(":8080", handler)
}

type greetForm struct {
    Name string `thx:"name"`
}

func getIndex(ctx thx.Context, _ struct{}) thx.Result {
    return thx.Render(ctx, indexPage(""))
}

func postGreet(ctx thx.Context, _ struct{}, form greetForm) thx.Result {
    return thx.Render(ctx, indexPage(form.Name))
}
templ baseLayout(inner templ.Component) {
    <!DOCTYPE html>
    <html>
    <head>
        <script src="https://unpkg.com/htmx.org@4"></script>
    </head>
    <body>
        @inner
    </body>
    </html>
}

templ indexPage(name string) {
    <form hx-post="/greet" hx-target="this" hx-swap="outerHTML">
        <input type="text" name="name" value={ name } placeholder="Your name" />
        <button type="submit">Greet</button>
    </form>
    if name != "" {
        <p>Hello, { name }!</p>
    }
}

HTMX partial requests automatically skip the layout and return just the form fragment. Full page loads get the complete HTML with <head> and scripts. No manual branching needed.

Protected routes

Your own middleware decides who the user is and puts them on the context with thx.SetAuth. thx never touches sessions, tokens, or passwords — it only enforces what you decide.

handler := thx.New(
    thx.Get("/login", getLogin),
    thx.Post("/login", postLogin),

    auth.WithGuard("/dashboard", dashboardRoutes(),
        auth.Authenticated[model.User](),
        auth.RedirectUnauthorized("/login"),
        auth.RedirectWithCurrentPath("next"),
    ),
)

Unauthenticated requests get a 401, or a redirect if one is configured. HTMX requests are redirected via HX-Redirect, so the login page replaces the document instead of being swapped into a fragment.

Authorization rules

A Rule decides whether an authenticated subject may proceed. It returns nil to allow, auth.Forbidden(reason) to deny with a 403, and any other error to signal that the decision could not be made at all — which becomes a 500, never a silent denial.

type Rule[T any] func(ctx thx.Context, subject T) error

Rules compose with auth.All, auth.Any, and auth.Not. auth.Check builds one from a plain predicate:

var isAdmin = auth.Check(func(u model.User) bool { return u.Role == "admin" }, "admin only")

func hasCredit(ctx thx.Context, u model.User) error {
    balance, err := billing.Balance(ctx, u.ID)
    if err != nil {
        return err // 500 — not a denial
    }
    if balance <= 0 {
        return auth.Forbidden("no credit left")
    }
    return nil
}

auth.WithGuard("/admin", adminRoutes(), auth.All(isAdmin, hasCredit))

Rules attach at two levels:

Guard (auth.WithGuard) Route (auth.Get, auth.Route)
Runs as middleware, before routing inside the matched route
Covers every path under the prefix, including 404s that one route
ctx.Param not yet parsed — empty available

So coarse gating belongs on the guard, and anything that depends on a path parameter belongs on the route:

func ownsUser(ctx thx.Context, u model.User) error {
    if ctx.Param("id") != strconv.Itoa(u.ID) {
        return auth.Forbidden("not your account")
    }
    return nil
}

thx.Get("/users/{id}", auth.Get(c.getUser, ownsUser))

Route-level denials reuse the enclosing guard's denial handling, so a RedirectForbidden or OnForbidden configured once applies everywhere inside it:

auth.WithGuard("/dashboard", dashboardRoutes(), isAdmin,
    auth.OnForbidden(func(ctx thx.Context, err error) thx.Result {
        return thx.Status(http.StatusForbidden).Render(ctx, deniedPage(auth.Reason(err)))
    }),
)

Because the guard is middleware, it cannot apply layouts to that page — a denial rendered from the guard is a bare fragment. For a fully laid-out 403, redirect to a real route instead, or deny at the route level where layouts are in scope.

Application code usually aliases the type parameter away once:

package auth // your app's package

type (
    Context = thxauth.Context[model.User]
    Rule    = thxauth.Rule[model.User]
)

See the reference app in internal/test/ for a complete working example with auth, layouts, SSE, and WebSocket.

Recipes and patterns

Common interaction patterns for use with thx. These are application-level patterns, not framework features.

Passing data from handlers to layouts

Inner components cannot propagate data outward to layouts during rendering — by the time a component's Render() executes, the layout's <head> has already been written. Instead, set metadata on the context in the handler before returning. Layouts read it during rendering.

// Define context keys in your app
type titleKey struct{}
type breadcrumbsKey struct{}

// Handler sets metadata before returning
func (c *Controller) getUser(ctx thx.Context, q userQuery) thx.Result {
    user := loadUser(q.ID)
    ctx.SetValue(titleKey{}, user.Name + " - Profile")
    ctx.SetValue(breadcrumbsKey{}, []Crumb{{"Home", "/"}, {"Users", "/users"}, {user.Name, ""}})
    return thx.Render(ctx, userProfile(user))
}
// Layout reads from context
templ baseLayout(inner templ.Component) {
    <html>
    <head>
        if title, ok := thx.ViewContext(ctx).Value(titleKey{}).(string); ok {
            <title>{ title }</title>
        }
    </head>
    <body>
        @inner
    </body>
    </html>
}

This works because the handler runs first, sets context values, then returns a Result. When the layout renders, it reads those values. The inner component renders last.

For HTMX partial requests, layouts are skipped entirely, so this is purely a full-page concern — partials don't need it.

Form and query binding

The I and Q type parameters are filled by thx's built-in decoder. Fields are matched by their thx tag (falling back to the Go field name), and thx:"-" skips a field. Keys are dotted paths, so repeatable form rows and dynamic key sets bind directly:

type orderForm struct {
    Customer struct {
        Name string `thx:"name"`
    } `thx:"customer"`

    Items []struct {
        SKU string `thx:"sku"`
        Qty int    `thx:"qty"`
    } `thx:"items"`

    Tags  []string          `thx:"tags"`
    Attrs map[string]string `thx:"attrs"`
}
<input name="customer.name" value="Marc">
<input name="items.0.sku" value="A-1"><input name="items.0.qty" value="2">
<input name="items.1.sku" value="B-7"><input name="items.1.qty" value="1">
<input name="tags" value="rush"><input name="tags" value="gift">
<input name="attrs.color" value="red">

Indices may arrive in any order and may be sparse — missing elements stay zero. Slices also accept repeated keys without an index (tags twice above), scalars take the last value. Supported leaf types are strings, bools, ints, uints, floats, []byte, pointers to those, and anything implementing encoding.TextUnmarshaler.

Two browser realities are handled for you: a checked checkbox submits on, which decodes to true, and an untouched number, date, or select input submits an empty string, which leaves the field at its zero value instead of failing. For a *string the empty string is kept as a value, since blank and absent differ there.

Unknown keys are ignored in query strings but rejected in form bodies, and indices are capped (1000) along with key depth (32) so a client cannot force a large allocation.

Decode failures are a *thx.DecodeError with a Kind — thx.ErrUnknownKey, thx.ErrMalformedValue, thx.ErrLimitExceeded, or thx.ErrInvalidTarget — and the offending Key. The kind separates a bad request from a bad handler: the first three answer 400, while ErrInvalidTarget (the I or Q struct itself cannot be decoded into) answers 500, since no request can fix it. Every failure is logged with its kind and key before the error page renders, and both errors.Is(err, thx.ErrMalformedValue) and errors.As(err, &decErr) work on it.

Decoding only fills the struct — validation stays in the handler, see the next section.

Form validation with field-level errors

thx decodes form data into your I struct automatically but leaves validation to the handler. This gives you full control over how errors are presented — especially important for HTMX form submissions where you want to re-render the form with inline errors.

Use any validation library you prefer. Here's an example with go-playground/validator:

import "github.com/go-playground/validator/v10"

var validate = validator.New(validator.WithRequiredStructEnabled())

type registerForm struct {
    Email    string `thx:"email"    validate:"required,email"`
    Password string `thx:"password" validate:"required,min=8"`
}

In the handler, validate and re-render with errors:

func (c *Controller) postRegister(ctx thx.Context, _ struct{}, form registerForm) thx.Result {
    errs := validateStruct(form)
    if errs != nil {
        return thx.Render(ctx, registerView(form, errs))
    }

    // ... create user, set cookie, etc.
    return ctx.Redirect("/")
}

A small helper converts validator errors into a map your templates can use:

type FieldErrors map[string]string

func (e FieldErrors) Has(field string) bool    { return e[field] != "" }
func (e FieldErrors) Get(field string) string  { return e[field] }

func validateStruct(v any) FieldErrors {
    err := validate.Struct(v)
    if err == nil {
        return nil
    }
    errs := make(FieldErrors)
    for _, fe := range err.(validator.ValidationErrors) {
        errs[fe.Field()] = fe.Error()
    }
    return errs
}

In your templ template, use the errors to highlight fields:

templ registerView(form registerForm, errs FieldErrors) {
    <form method="post">
        <input type="email" name="email" value={ form.Email }
               class={ templ.KV("input-error", errs.Has("Email")) } />
        if errs.Has("Email") {
            <span class="error">{ errs.Get("Email") }</span>
        }

        <input type="password" name="password"
               class={ templ.KV("input-error", errs.Has("Password")) } />
        if errs.Has("Password") {
            <span class="error">{ errs.Get("Password") }</span>
        }

        <button type="submit">Register</button>
    </form>
}

This pattern works with both full-page submissions and HTMX partials. The handler always runs, so you keep the submitted form values and can re-render with errors inline. No framework magic — just your validation library, your error format, your templates.

Active search (debounced)

A search input that queries the server on keyup with a 500ms debounce:

<input type="search" name="q"
    hx-get="/search"
    hx-trigger="keyup changed delay:500ms, search"
    hx-target="#results"
    hx-swap="innerHTML"
    hx-indicator="#spinner" />
<div id="results"></div>

The handler receives the query via thx's typed Q parameter and returns a partial result list.

Infinite scroll

Trigger loading the next page when the last row becomes visible:

<tr hx-get="/items?page=3"
    hx-trigger="intersect once"
    hx-swap="afterend"
    hx-indicator="#loading">
    <!-- last visible row -->
</tr>

The handler returns more <tr> elements. The last row in each batch carries the next hx-get to continue the chain.

Lazy load

Load expensive content after the page renders:

<div hx-get="/dashboard/stats"
     hx-trigger="load"
     hx-target="this"
     hx-swap="outerHTML">
    <!-- placeholder / skeleton -->
</div>

The handler returns the full component which replaces the placeholder.

Pagination

Each page button swaps the entire table/list container:

<button hx-get="/users?page=2"
        hx-target="#user-list"
        hx-swap="outerHTML">
    2
</button>

Use thx's typed query params to decode the page number. The response includes the list and updated pagination controls.

Caching

thx offers three caching primitives, all with a TTL. Store the returned closure in a package-level variable so the cache persists across requests.

Cached and CachedByKey memoize a templ.Component. The component is built once (per key) and re-rendered with the live request context on every request, so ViewContext and layouts still work. Use them when the expensive part is building the component from already-available data:

var sidebar = thx.Cached(5*time.Minute, func() templ.Component {
    return sidebarView(loadNavItems())
})

var userCard = thx.CachedByKey[string](time.Minute, func(userID string) templ.Component {
    return userCardView(loadUser(userID))
})

func (c *Controller) getPage(ctx thx.Context, _ struct{}) thx.Result {
    return thx.Render(ctx, sidebar())        // wrapped in layouts as usual
}

CachedPartial caches the rendered bytes of a partial, keyed by a request-derived key (e.g. locale). Unlike the two above, its factory receives the request context.Context — so it can load data with the live context on a miss — and may return an error, which is surfaced to the caller (HTTP 500) and never cached. Because it caches output rather than a component, consumers that bind their locale at view-context construction stay correct as long as the key includes the locale. Reach for it when the miss-time work is request-scoped or can fail (e.g. a live external API call):

var greeting = thx.CachedPartial(time.Minute,
    func(ctx context.Context) string { return view.Lang(ctx) },   // cache key
    func(ctx context.Context) (templ.Component, error) {
        data, err := api.FetchGreeting(ctx)
        if err != nil {
            return nil, err
        }
        return greetingView(data), nil
    },
    thx.WithCacheControl(),                                        // optional
)

func (c *Controller) getGreeting(ctx thx.Context, _ struct{}) thx.Result {
    return greeting(ctx)                                          // returns a Result directly
}

CachedPartial renders without layouts (like Partial). On a miss or once an entry is stale it re-renders synchronously, single-flighted so a burst of callers triggers one render they all share. If a re-render fails while stale bytes are still held, the stale bytes are served (availability over freshness). WithCacheControl() adds Cache-Control: private, max-age=<ttl> so clients cache the fragment for the same duration.

Flash messages and toast notifications

There are three patterns depending on the context. Use the right one for each situation.

The simplest approach for HTMX form submissions. The server triggers a client-side event, and a small JS listener renders the toast. This works alongside a normal content swap — a single response can update the page AND show a notification.

func (c *Controller) postSomething(ctx thx.Context, _ struct{}, form myForm) thx.Result {
    // ... do work ...
    ctx.HTMX().Trigger("showToast", map[string]any{"level": "success", "message": "Saved!"})
    return thx.Render(ctx, updatedContent())
}
// ~5 lines in your base layout
document.body.addEventListener("showToast", (e) => {
    const { level, message } = e.detail;
    // render your toast UI however you like
});

For error cases where you don't want to swap content, combine with Reswap:

ctx.HTMX().Reswap(thx.SwapNone)
ctx.HTMX().Trigger("showToast", map[string]any{"level": "error", "message": "Validation failed"})
return thx.Empty()
HTMX requests: OOB swap (fully server-rendered)

If you want toasts rendered entirely server-side (no client JS for toast construction), use OOB swaps. Your layout needs a <div id="flashes"></div> container, and the server appends toast HTML as an OOB fragment.

func (c *Controller) postSomething(ctx thx.Context, _ struct{}, form myForm) thx.Result {
    // ... do work ...
    return thx.SwapOOB(ctx, updatedContent(),
        thx.OOBWithStrategy("#flashes", thx.SwapAfterBegin, toastSuccess("Saved!")),
    )
}

Using afterbegin appends new toasts without clearing existing ones.

HTMX requests: automatic flash OOB

If every handler ends up repeating the same OOB swap, opt into WithFlashOOB. It appends pending flash messages to every rendered HTML response of the wrapped routes, so handlers only call thx.Flash*:

thx.New(
    thx.WithFlashOOB("#flashes", thx.SwapAfterBegin, toasts,
        thx.Get("/dashboard", c.getDashboard),
        thx.Post("/settings", c.postSettings),
    ),
)

// toasts is yours — thx never dictates the container or the markup
templ toasts(flashes []thx.FlashMessage) {
    for _, f := range flashes {
        @toast(f.Level, f.Message)
    }
}
func (c *Controller) postSettings(ctx thx.Context, _ struct{}, form settingsForm) thx.Result {
    // ... do work ...
    thx.FlashSuccess(ctx, "Saved!")
    return thx.Render(ctx, updatedContent())
}

Only HTMX partial requests are touched. Boosted and history-restore responses replace the whole body, which would wipe the element the swap just wrote into — those render with layouts, so the layout's own Flashes call displays them as on a normal page load. On partial requests the swap owns the flashes: they are consumed before the component renders, so Flashes returns nil afterwards. Responses that are not rendered HTML — redirects, JSON, Raw, Empty — leave the cookie intact, so a flash set before ctx.Redirect still shows on the next page load. CachedPartial results get the swap too, except with WithCacheControl(), where a one-time message must not end up in the browser cache. The swap is appended after any explicit SwapOOB swaps.

One caveat: flashes are consumed before the response body is written, because clearing the cookie requires an unwritten header. If the primary component then fails mid-render, the response becomes a 500 and the messages are gone — the cookie is already deleted, so there is nothing left to retry on the next request. Buffering every render to close that window costs streaming on all responses to salvage an error path, so it is not done. Flashes that must survive a failed render belong in your own storage, not in a one-shot cookie.

For redirects that leave the HTMX context (login, logout, initial navigation), use thx's cookie-based flash messages. These survive across the redirect and are consumed on the next page load.

// POST handler — set flash before redirect
func (c *Controller) postLogin(ctx thx.Context, _ struct{}, form loginForm) thx.Result {
    // ... authenticate ...
    thx.FlashSuccess(ctx, "Welcome back!")
    return ctx.Redirect("/dashboard")
}

// GET handler — read and display flashes
func (c *Controller) getDashboard(ctx thx.Context, _ struct{}) thx.Result {
    flashes := thx.Flashes(ctx) // reads and clears cookie
    return thx.Render(ctx, dashboardView(flashes))
}

Note: HX-Trigger headers on 3xx responses are ignored by HTMX, so cookie-based flashes are the only option for redirects.

Out-of-band updates

Update multiple page sections in a single response using thx's OOB helpers:

func (c *Controller) postComment(ctx thx.Context, _ struct{}, form commentForm) thx.Result {
    // ... save comment ...
    return thx.SwapOOB(ctx, commentList(comments),
        thx.OOBWithStrategy("#comment-count", thx.SwapInnerHTML, commentCount(len(comments))),
    )
}

The primary component renders normally with layouts. OOB swaps are appended for HTMX requests only.

Intentionally not included

CSRF protection

thx does not include CSRF token middleware. For a typical thx application, browser-level protections are sufficient:

  • SameSite=Lax cookies — thx sets this on all cookies by default. The browser will not send auth cookies on cross-origin POST/PUT/DELETE form submissions, which prevents classic CSRF attacks.
  • HTMX custom headers — HTMX adds HX-Request: true on every request. Custom headers trigger CORS preflight, so cross-origin HTMX requests are blocked unless the server explicitly allows them.

Together, these cover the standard CSRF attack surface without tokens. If your application has unusual requirements (complex subdomain trust models, very old browser support), use a dedicated CSRF middleware like gorilla/csrf or justinas/nosurf via thx.WithMiddleware.

Request ID

Request ID generation (for logging/tracing correlation) is a standard HTTP middleware — it generates a UUID, stores it in context, and sets a response header. There is no framework integration needed. Use any existing middleware or write your own:

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        ctx := context.WithValue(r.Context(), requestIDKey{}, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Wire it in with thx.WithMiddleware(RequestID, ...).

Flash conventions by default

Auto-appended flash OOB swaps are opt-in via WithFlashOOB, never on by default. The container selector, the swap strategy, and the markup all come from the application — thx supplies no toast component, no #flashes convention, and no client JS. Toast presentation varies wildly between apps (libraries, animations, positioning, stacking), so the framework only handles the plumbing: consume pending flashes, render the app's component, append it as an OOB template.

Without the opt-in, the building blocks stand alone: ctx.HTMX().Trigger() for HTMX requests (~5 lines of client JS), thx.SwapOOB() for fully server-rendered toasts, and thx.Flash() for redirects.

See Flash messages and toast notifications in the recipes section for all patterns.

References and inspiration

go-htmx

A per-request HTMX helper layer for Go. Wraps http.ResponseWriter to provide typed access to all HTMX request/response headers, plus fluent builders for swap strategies and trigger events. Framework-agnostic with zero dependencies. Includes a html/template-based component system, SSE broadcast manager, and toast notification shortcuts via HX-Trigger (baked-in showMessage event with level/message payload). thx adopts the idea of typed swap and trigger builders but intentionally leaves notification conventions to the application. Otherwise covers the same ground more deeply — automatic partial rendering, Vary headers, type-safe generic handlers, route composition, and templ integration are all built-in rather than left to the developer.

htmx-go

A stateless, functional Go package for HTMX header handling with zero dependencies. Provides package-level functions for reading request headers and a Response builder (value type, chainable) for setting response headers. Features a SwapStrategy typed string with modifier methods that deduplicate by prefix, three trigger types (Trigger, TriggerDetail, TriggerObject) with automatic JSON serialization, and templ integration via duck-typed interface (no import dependency). Notable for PreventPushURL/PreventReplaceURL and scroll:window/show:window modifiers. thx adopted swap modifier deduplication, window scroll/show targets, prevent push/replace URL, and native time.Duration formatting from this project's approach. thx goes further with automatic partial rendering, Vary headers, context-integrated HTMX API, layout composition, and SSE/WebSocket support.

htmgo

A full Go+HTMX framework that replaces templating with a pure Go HTML DSL — every element is a function call (h.Div(h.Class("..."), h.Text("..."))) with attributes and children as variadic arguments. Built on chi router with convention-based routing via AST code generation (pages in pages/, partials in partials/). Features type-safe partial references via reflection, a rich JS command system for common interactions without writing JavaScript (OnClick(h.ToggleClass("hidden"))), component-level caching with TTL and per-key variants, OOB swap helpers for multi-target updates, and query param fallback to HX-Current-URL for partial handlers. thx adopted the ideas of component caching, OOB swap helpers, and query param fallback from this project. thx takes a different path overall — templ for HTML, generic type parameters for type-safe form/query binding, automatic layout composition and partial rendering, and stdlib-only routing without code generation.

goshipit

A templ component library wrapping DaisyUI 5, distributed via a CLI that copies .templ source files into your project (shadcn/ui model). Not a framework — no routing, no middleware. Provides ~45 pre-built components with consistent props-struct APIs. The HTMX-integrated components (active search, infinite scroll, pagination, date picker, lazy load) demonstrate the real patterns every Go+HTMX app needs, though with hardcoded URLs and implicit handler contracts. Notable for a server-driven toast pattern using HX-Retarget: body + HX-Reswap: beforeend to inject notifications from any handler without client-side JS listeners. thx does not ship UI components — that is application territory — but documents the HTMX interaction patterns as recipes (see above). thx's type-safe routing would make these patterns compile-time safe rather than relying on string URLs.

Go Axum Handlers (+Part 2)

Explores bringing Rust's axum pattern to Go: handler functions declare their needs through parameter types (each implementing an Extractor interface), and the framework automatically parses requests and serializes responses. Part 2 optimizes this by packing extractors into a struct and using unsafe.Pointer field offsets to avoid per-request reflection.

The pattern is powerful for general-purpose REST APIs with many diverse input sources, but requires reflection or unsafe code to inspect handler signatures at runtime. thx takes a different approach: fixed generic type parameters [Q, I, O] (query, input, output) give fully type-safe extraction at compile time with zero reflection — a simpler trade-off that fits the narrow templ+htmx domain well.

gorilla/schema

The de-facto standard for filling Go structs from url.Values. Matches keys via a configurable alias tag, descends into nested structs and slices with dotted and indexed keys (user.addr.city, items.0.name), supports map fields, per-type converters, IgnoreUnknownKeys, ZeroEmpty for blank inputs, and aggregates every failure into a MultiError keyed by field path instead of stopping at the first one. thx has a built-in decoder in the same spirit — the thx struct tag, dotted keys for nested structs, registered converters, encoding.TextUnmarshaler support, and a type-keyed field cache warmed at route registration so no per-request type walk happens. Dotted and indexed keys, map fields, and blank-input-means-zero (gorilla's ZeroEmpty) are all present. It deliberately stays smaller: no dependency, and one error at a time rather than a MultiError — validation and field-level error reporting are the handler's job in thx, so aggregating decode errors would duplicate it. Unknown keys are fatal for form bodies but ignored for query strings, which matches how the two are actually used.

ajg/form

An encoder and decoder for application/x-www-form-urlencoded, shaped like encoding/json. Flattens arbitrarily nested maps, slices, arrays, and structs into dot-delimited paths, with the delimiter and escape character configurable (DelimitWith, EscapeWith) so literal dots in keys round-trip. Handles time.Time, url.URL, math/big, and image/color as leaf values, falls back to json tags, supports omitempty, IgnoreCase, and a KeysWith hook to rewrite field names (e.g. to snake_case). Notable for treating untrusted input as untrusted: MaxSize, MaxDepth, and MaxBytes bound allocation, and failures come back as a typed *form.Error classifying parse errors, unknown keys, and limit violations. thx takes the decode-only half — it never needs to encode, since templ renders the HTML. It adopted four ideas from this project: indexed keys for repeatable form rows (items.0.name), map fields for dynamic key sets, bounded indices and key depth so those two cannot be turned into an allocation attack, and errors classified by kind so a malformed request can be told apart from an undecodable target struct. The encoder, complex numbers, colors, math/big, and configurable delimiters are outside what an htmx form submission needs.

Documentation

Index

Constants

View Source
const (
	SwapInnerHTML   = "innerHTML"
	SwapOuterHTML   = "outerHTML"
	SwapBeforeBegin = "beforebegin"
	SwapAfterBegin  = "afterbegin"
	SwapBeforeEnd   = "beforeend"
	SwapAfterEnd    = "afterend"
	SwapDelete      = "delete"
	SwapNone        = "none"
	SwapInnerMorph  = "innerMorph"
	SwapOuterMorph  = "outerMorph"
	SwapTextContent = "textContent"
)

Swap strategies for HX-Reswap / hx-swap.

Variables

This section is empty.

Functions

func Cached

func Cached(ttl time.Duration, factory func() templ.Component) func() templ.Component

Cached wraps a templ component factory with time-based caching. The factory is called once and the resulting component is reused for the given duration. After expiry, the next call re-invokes the factory. The component is rendered with the actual request context each time, so thx.ViewContext and layouts work correctly.

var sidebar = thx.Cached(5*time.Minute, func() templ.Component {
    return sidebarTemplate(loadNavItems())
})

Use in a handler: thx.Render(ctx, sidebar())

func CachedByKey

func CachedByKey[K comparable](ttl time.Duration, factory func(K) templ.Component) func(K) templ.Component

CachedByKey wraps a keyed templ component factory with per-key caching. Each unique key gets its own cached component with the given TTL. Expired entries are evicted on cache miss.

var userCard = thx.CachedByKey[string](time.Minute, func(userID string) templ.Component {
    return userCardTemplate(loadUser(userID))
})

Use in a handler: thx.Render(ctx, userCard("user-123"))

func CachedPartial

func CachedPartial(
	ttl time.Duration,
	keyFn func(context.Context) string,
	factory func(context.Context) (templ.Component, error),
	opts ...CachedOption,
) func(Context) Result

CachedPartial caches the rendered bytes of a partial, keyed by a request-derived key (e.g. locale), and refreshes them every ttl.

Unlike Cached, the factory receives a context, so it can load data on a cache miss, and it may return an error — which is surfaced to the caller (HTTP 500) and never cached. That context carries the request's values but is detached from its cancellation, so an aborted fetch (navigation, reload, HTMX swap) cannot cancel a build the whole TTL window — and every other caller waiting on the same flight — depends on. It is bounded by its own timeout instead; see WithBuildTimeout. Output is cached as bytes rather than as a component, so consumers that bind their locale at view-context construction stay correct as long as the key includes the locale.

A cache hit within ttl serves the cached bytes directly. On a miss or once an entry is stale, the factory is re-rendered synchronously, single-flighted across concurrent callers so a burst triggers one render they all share. If a re-render fails but stale bytes are still held, the stale bytes are served (availability over freshness); a cold render error is surfaced to the caller (HTTP 500) and never cached. The component is rendered without layouts, like Partial.

var greeting = thx.CachedPartial(time.Minute,
    func(ctx context.Context) string { return view.Lang(ctx) },
    func(ctx context.Context) (templ.Component, error) {
        data, err := api.FetchGreeting(ctx)
        if err != nil {
            return nil, err
        }
        return greetingView(data), nil
    },
    thx.WithCacheControl(),
)

// Use in a handler:
func (c *Controller) getGreeting(ctx thx.Context, _ struct{}) thx.Result {
    return greeting(ctx)
}

func Chain

func Chain(mws ...func(http.Handler) http.Handler) func(http.Handler) http.Handler

Chain composes multiple middleware into a single middleware. Middleware are applied in the order given: the first middleware in the list is the outermost wrapper.

func Flash

func Flash(ctx Context, level, message string)

Flash stores a flash message that will be available on the next request. Typically called before a redirect. Common levels: "success", "error", "info", "warning". Messages are truncated to 200 runes and at most 5 messages are stored per redirect. Stored in an HttpOnly cookie.

func FlashError

func FlashError(ctx Context, message string)

FlashError is a shorthand for Flash(ctx, "error", message).

func FlashInfo

func FlashInfo(ctx Context, message string)

FlashInfo is a shorthand for Flash(ctx, "info", message).

func FlashSuccess

func FlashSuccess(ctx Context, message string)

FlashSuccess is a shorthand for Flash(ctx, "success", message).

func FlashWarning

func FlashWarning(ctx Context, message string)

FlashWarning is a shorthand for Flash(ctx, "warning", message).

func New

func New(routes ...Route) http.Handler

New creates an http.Handler from the given routes. It builds an http.ServeMux, applies all routes, and wraps the result with any top-level middleware and not-found handling.

func Scope

func Scope(prefix string) func(string) string

Scope generates an identifier specific to the current execution context. It uses the current stack trace to create a unique identifier, which is then base64 encoded and prefixed with the provided string. This is useful for generating unique ids for elements in a web application.

Note: Make sure to store the result in a package variable, as it is expensive to compute.

func SetAuth

func SetAuth[T any](ctx context.Context, auth T) context.Context

SetAuth stores the authenticated entity in the context. Call this in your auth middleware after verifying the user. The entity can later be retrieved via auth.Context[T].Auth().

Types

type CachedOption

type CachedOption func(*cachedConfig)

CachedOption configures CachedPartial.

func WithBuildTimeout added in v0.2.0

func WithBuildTimeout(timeout time.Duration) CachedOption

WithBuildTimeout overrides how long a detached build may run before its context is cancelled. Defaults to 30 seconds. A non-positive duration leaves the default in place.

func WithCacheControl

func WithCacheControl() CachedOption

WithCacheControl makes CachedPartial emit a "Cache-Control: private, max-age=<ttl>" response header, letting the client cache the fragment for the same duration the server does.

type Context

type Context = internal.Context

Context is the request-scoped context passed to all handlers. It extends context.Context with HTTP helpers for cookies, headers, path params, HTMX integration, redirects, and layout control.

func ViewContext

func ViewContext(ctx context.Context) Context

ViewContext retrieves the thx Context from a standard context.Context. Use this inside templ components to access request-scoped helpers like HTMX(), Param(), or Cookie(). Panics if called outside a thx request.

type DecodeError

type DecodeError struct {
	Kind DecodeErrorKind
	Key  string
	Err  error
}

DecodeError is the error returned by form and query decoding. Kind separates client mistakes (ErrUnknownKey, ErrMalformedValue, ErrLimitExceeded) from programming mistakes (ErrInvalidTarget), and Key names the offending input.

func (*DecodeError) Error

func (e *DecodeError) Error() string

func (*DecodeError) Is

func (e *DecodeError) Is(target error) bool

Is matches a DecodeError against its kind, so errors.Is(err, ErrUnknownKey) works without unwrapping to a *DecodeError first.

func (*DecodeError) Unwrap

func (e *DecodeError) Unwrap() error

type DecodeErrorKind

type DecodeErrorKind string

DecodeErrorKind classifies why decoding failed. It is itself an error, so a kind can be matched with errors.Is:

if errors.Is(err, thx.ErrUnknownKey) { /* client sent a key we don't know */ }
const (
	// ErrUnknownKey means the key does not address any field of the target
	// struct. Only returned for form bodies; query strings ignore extra keys.
	ErrUnknownKey DecodeErrorKind = "unknown key"

	// ErrMalformedValue means the key addressed a field, but the submitted
	// value could not be converted to that field's type.
	ErrMalformedValue DecodeErrorKind = "malformed value"

	// ErrLimitExceeded means the key exceeded a decoder limit: too many path
	// segments, or an index beyond what a client may address.
	ErrLimitExceeded DecodeErrorKind = "limit exceeded"

	// ErrInvalidTarget means the decode destination itself is wrong — a bug in
	// the handler's type parameters, not in the request.
	ErrInvalidTarget DecodeErrorKind = "invalid target"
)

func (DecodeErrorKind) Error

func (k DecodeErrorKind) Error() string

type EventStream

type EventStream interface {
	// Send writes a data-only SSE message.
	Send(data string) error
	// SendEvent writes an SSE message with a named event type.
	SendEvent(event string, data string) error
	// SendJSON marshals data as JSON and sends it as an SSE data field.
	SendJSON(data any) error
}

EventStream provides methods for sending Server-Sent Events to the client.

type FlashMessage

type FlashMessage struct {
	Level   string `json:"level"`
	Message string `json:"message"`
}

FlashMessage is a one-time message that survives a redirect.

func Flashes

func Flashes(ctx Context) []FlashMessage

Flashes reads and clears all flash messages. Returns nil if there are none. The cookie is deleted after reading. Subsequent calls in the same request return nil — flashes are consumed exactly once.

type FlashOOBRenderer

type FlashOOBRenderer func([]FlashMessage) templ.Component

FlashOOBRenderer turns pending flash messages into a component that is appended to HTMX responses as an out-of-band swap.

type Layout

type Layout func(templ.Component) templ.Component

Layout wraps a templ component with surrounding markup (e.g. a page shell). Layouts nest innermost-first: the first layout in the slice wraps the component directly, and each subsequent layout wraps the previous result.

type LocationOptions

type LocationOptions = internal.LocationOptions

LocationOptions configures a client-side HTMX navigation via HX-Location.

type OOBSwap

type OOBSwap struct {
	// contains filtered or unexported fields
}

OOBSwap represents an out-of-band swap targeting a specific element.

func OOB

func OOB(comp templ.Component) OOBSwap

OOB creates an out-of-band swap that replaces the element matching the component's root element ID (using hx-swap-oob="true").

func OOBWithStrategy

func OOBWithStrategy(selector string, strategy string, comp templ.Component) OOBSwap

OOBWithStrategy creates an out-of-band swap with a specific swap strategy targeting the given CSS selector.

thx.OOBWithStrategy("#notifications", thx.SwapInnerHTML, notificationList())

type Result

type Result = internal.Result

Result is the return type of all route handlers. It writes the handler's output to the HTTP response.

func Empty

func Empty() Result

Empty returns a Result that writes nothing to the response.

func JSON

func JSON(data any) Result

JSON returns a Result that serializes data as JSON with the appropriate Content-Type header.

func Partial

func Partial(ctx Context, comp templ.Component) Result

Partial returns a Result that renders the component without any layouts. Useful for returning HTML fragments for HTMX partial updates.

func Raw

func Raw(val string) Result

Raw returns a Result that writes the string as plain text.

func Render

func Render(ctx Context, comp templ.Component) Result

Render returns a Result that renders the templ component with all active layouts applied. This is the primary way to return HTML.

func SwapOOB

func SwapOOB(ctx Context, primary templ.Component, swaps ...OOBSwap) Result

SwapOOB returns a Result that renders the primary component followed by one or more out-of-band swaps. For non-HTMX requests, only the primary component is rendered.

type Route

type Route interface {
	Apply(*Router)
}

Route is the building block for defining HTTP routes. Implementations register themselves on a Router via Apply.

func Delete

func Delete[Q, I any](path string, handler func(Context, Q, I) Result) Route

Delete registers a DELETE route at the given path. Q is the query parameter type and I is the request body input type.

func Get

func Get[Q any](path string, handler func(Context, Q) Result) Route

Get registers a GET route at the given path. Q is the query parameter type. The handler receives the decoded query parameters and returns a Result.

func HandleInternalError

func HandleInternalError(comp func(Context) templ.Component) Route

HandleInternalError registers a custom error page rendered when a handler panics or returns an error. The component receives the request context.

func HandleNotFound

func HandleNotFound(comp func(Context) templ.Component) Route

HandleNotFound registers a custom 404 page rendered with the given component. The component receives the request context so it can access path values, HTMX state, etc.

func Patch

func Patch[Q, I any](path string, handler func(Context, Q, I) Result) Route

Patch registers a PATCH route at the given path. Q is the query parameter type and I is the request body input type.

func Post

func Post[Q, I any](path string, handler func(Context, Q, I) Result) Route

Post registers a POST route at the given path. Q is the query parameter type and I is the request body input type (form or JSON).

func Put

func Put[Q, I any](path string, handler func(Context, Q, I) Result) Route

Put registers a PUT route at the given path. Q is the query parameter type and I is the request body input type.

func SSE

func SSE[Q any](path string, handler func(ctx Context, query Q, stream EventStream)) Route

SSE registers a Server-Sent Events route at the given path. The handler receives an EventStream for pushing events to the client. The connection stays open until the handler returns or the client disconnects.

func Static

func Static(prefix string, fsys fs.FS) Route

Static serves static files from the given filesystem at the given URL prefix. Files requested with a "v" query parameter get immutable cache headers (1 year, immutable). Files without it get standard caching (no-cache).

func WS

func WS[Q any](path string, handler func(ctx Context, query Q, conn *WSConn), acceptOpts ...*websocket.AcceptOptions) Route

WS registers a WebSocket route at the given path. The handler receives a WSConn for bidirectional communication. Optional AcceptOptions configure the WebSocket upgrade; by default, origin checking is skipped.

type Router

type Router struct {
	// Mux is the underlying ServeMux. Exposed for direct handler registration.
	Mux *http.ServeMux
	// contains filtered or unexported fields
}

Router holds the routing state accumulated while applying routes. It wraps a standard http.ServeMux and tracks layouts, middleware, and error handlers for the current scope.

type Routes

type Routes []Route

Routes is an ordered collection of Route values that applies as a group.

func WithFlashOOB

func WithFlashOOB(selector string, strategy string, render FlashOOBRenderer, routes ...Route) Routes

WithFlashOOB makes pending flash messages render automatically on every HTML response of the given routes, without calling SwapOOB by hand. The selector and strategy are passed through to OOBWithStrategy, and the renderer produces the markup — thx dictates no container or toast markup.

thx.WithFlashOOB("#flashes", thx.SwapAfterBegin, toasts,
    thx.Get("/dashboard", dashboard),
)

Only HTMX requests are touched (history restores excluded, since HTMX ignores OOB swaps there). On those requests the OOB swap owns the flashes: they are consumed before the primary component renders, so a layout calling Flashes gets nil. Responses that are not rendered HTML — redirects, JSON, raw — leave the flash cookie intact for the next page load.

func WithLayout

func WithLayout(layout Layout, routes ...Route) Routes

WithLayout wraps all given routes with an additional layout. The new layout becomes the innermost wrapper, closest to the rendered component. Layouts from parent scopes remain outermost.

func WithMiddleware

func WithMiddleware(mw func(http.Handler) http.Handler, routes ...Route) Routes

WithMiddleware wraps the given routes with an HTTP middleware. The middleware sees the original request before any route-level processing.

func WithPath

func WithPath(path string, routes ...Route) Routes

WithPath groups routes under a URL path prefix. A sub-mux is created so that the grouped routes share middleware and not-found handling. Trailing slashes are redirected to the canonical path without a slash.

func (Routes) Apply

func (r Routes) Apply(router *Router)

Apply registers all routes in the collection on the given router.

type StatusBuilder

type StatusBuilder struct {
	// contains filtered or unexported fields
}

StatusBuilder constructs results with a specific HTTP status code.

func Status

func Status(code int) *StatusBuilder

Status begins building a Result with a custom HTTP status code. Chain with Render, JSON, Raw, or Empty to complete the result.

return thx.Status(http.StatusCreated).JSON(user)

func (*StatusBuilder) Empty

func (s *StatusBuilder) Empty() Result

Empty returns a Result that writes only the configured status code with no body.

func (*StatusBuilder) JSON

func (s *StatusBuilder) JSON(data any) Result

JSON returns a Result that serializes data as JSON with the configured status code.

func (*StatusBuilder) Raw

func (s *StatusBuilder) Raw(val string) Result

Raw returns a Result that writes the string as plain text with the configured status code.

func (*StatusBuilder) Render

func (s *StatusBuilder) Render(ctx Context, comp templ.Component) Result

Render returns a Result that renders the component with the configured status code.

type WSConn

type WSConn struct {
	// contains filtered or unexported fields
}

WSConn wraps a WebSocket connection with convenience methods for text, JSON, and HTMX message exchange.

func (*WSConn) Close

func (c *WSConn) Close(reason string) error

Close gracefully closes the WebSocket connection with a normal closure status.

func (*WSConn) Conn

func (c *WSConn) Conn() *websocket.Conn

Conn returns the underlying websocket.Conn for advanced use cases.

func (*WSConn) ReadHTMX

func (c *WSConn) ReadHTMX(ctx context.Context) (WSRequest, error)

ReadHTMX reads and decodes an HTMX 4.0 WebSocket request envelope.

func (*WSConn) ReadJSON

func (c *WSConn) ReadJSON(ctx context.Context, v any) error

ReadJSON reads a text message and unmarshals it as JSON into v.

func (*WSConn) ReadText

func (c *WSConn) ReadText(ctx context.Context) (string, error)

ReadText reads a single text message from the WebSocket connection.

func (*WSConn) Write

func (c *WSConn) Write(ctx context.Context, msg string) error

Write sends a text message over the WebSocket connection.

func (*WSConn) WriteHTMX

func (c *WSConn) WriteHTMX(ctx context.Context, msg WSMessage) error

WriteHTMX sends an HTMX 4.0 WebSocket response envelope.

func (*WSConn) WriteJSON

func (c *WSConn) WriteJSON(ctx context.Context, v any) error

WriteJSON marshals v as JSON and sends it as a text message.

type WSMessage

type WSMessage struct {
	Channel string `json:"channel,omitempty"`
	Format  string `json:"format,omitempty"`
	Target  string `json:"target,omitempty"`
	Swap    string `json:"swap,omitempty"`
	Payload string `json:"payload"`
}

WSMessage is the HTMX 4.0 WebSocket response envelope sent to the client.

type WSRequest

type WSRequest struct {
	Type      string         `json:"type"`
	RequestID string         `json:"request_id"`
	Event     string         `json:"event"`
	Path      string         `json:"path"`
	Headers   map[string]any `json:"headers"`
	Values    map[string]any `json:"values"`
}

WSRequest is the HTMX 4.0 WebSocket request envelope sent by the client.

Directories

Path Synopsis
cmd
thx command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL