A type-safe, reusable Go library pattern for building dynamic HTMX components with minimal boilerplate.
The HTMX Generic Component Registry eliminates repetitive handler code by providing a clean, type-safe abstraction for:
- Form parsing into typed structs using generics
- HTMX request headers (HX-Boosted, HX-Request, HX-Current-URL, etc.)
- HTMX response headers (HX-Redirect, HX-Trigger, HX-Reswap, etc.)
- Dynamic component routing via centralized registry
- Type-safe rendering with templ templates
- ✅ Type-safe - Uses Go 1.23+ generics for compile-time verification
- ✅ Zero boilerplate - Automatic form parsing and header handling
- ✅ Interface-based - Optional HTMX headers via clean interfaces
- ✅ Framework agnostic - Works with chi, gorilla/mux, net/http
- ✅ Composable - Mix and match header interfaces as needed
- ✅ Dual-use components - Same component works for HTMX requests AND server-side rendering
- ✅ Context-aware - Full context.Context support for database queries and cancellation
- ✅ Debug mode - Built-in debugging headers for development
- ✅ Thread-safe - Safe for concurrent use
- 📖 API Reference (pkg.go.dev) - Complete API documentation
- 📚 Documentation - Comprehensive guides and tutorials
- 🔍 Troubleshooting Guide - Step-by-step solutions to common problems
- 🐛 Common Gotchas - Known issues and workarounds
- 🧪 Testing Guide - Testing strategies and examples
- 🎯 Migration Guides - Migrate from React, Vue, or Svelte
You might wonder why we use:
components.Register[*MyComponent](registry, "name")Instead of a method like:
registry.Register[*MyComponent]("name") // ❌ Not possible in GoAnswer: Go methods cannot have type parameters. This is a fundamental limitation of Go generics (as of Go 1.23).
The Go team made this design decision because:
- Method dispatch complexity - Type parameters on methods would complicate dynamic dispatch
- Interface implementation - Would make it unclear how interfaces with generic methods work
- Simplicity - Keeps the type system simpler and more predictable
So we use a package-level generic function instead, which is the idiomatic Go approach for generic operations that need access to a struct.
Alternative approaches we considered:
- Builder pattern:
registry.Component("name").As[*Type]()- Still needs generic method ❌ - Reflection-only:
registry.Register("name", &Component{})- Loses type safety ❌ - Package function:
components.Register[*Type](registry, "name")- Works! ✅
Further reading:
go get github.com/ocomsoft/HxComponents1. Define your component struct that implements templ.Component:
package mycomponent
import (
"context"
"io"
)
type SearchComponent struct {
Query string `form:"q"`
Limit int `form:"limit"`
}
// Render implements templ.Component interface
func (c *SearchComponent) Render(ctx context.Context, w io.Writer) error {
return Search(*c).Render(ctx, w)
}2. Create a templ template:
package mycomponent
templ Search(data SearchComponent) {
<div>
<p>Query: { data.Query }</p>
<p>Limit: { fmt.Sprint(data.Limit) }</p>
</div>
}3. Register and serve:
package main
import (
"github.com/go-chi/chi/v5"
"github.com/ocomsoft/HxComponents/components"
"myapp/mycomponent"
)
func main() {
registry := components.NewRegistry()
components.Register[*mycomponent.SearchComponent](registry, "search")
router := chi.NewRouter()
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)
http.ListenAndServe(":8080", router)
}4. Use in HTML (HTMX):
<form hx-post="/component/search" hx-target="#results">
<input type="text" name="q" />
<input type="number" name="limit" value="10" />
<button>Search</button>
</form>
<div id="results"></div>5. Or use in templ templates (server-side):
templ MyPage() {
<h1>Search Results</h1>
// Use the component directly - it implements templ.Component!
@(&mycomponent.SearchComponent{Query: "golang", Limit: 10})
}The same component works both ways:
- As an HTMX component that handles dynamic POST/GET requests
- As a templ component that can be embedded in other templates
6. Or use with GET for initial state:
<!-- Load component with query parameters -->
<div hx-get="/component/search?q=golang&limit=5" hx-trigger="load" hx-target="this"></div>
<!-- Or via a link/button -->
<button hx-get="/component/search?q=htmx&limit=10" hx-target="#results">
Load Search Results
</button>The registry is framework-agnostic and works with any Go HTTP router or the standard library.
The simplest approach uses the Handler method with wildcard routing:
package main
import (
"github.com/go-chi/chi/v5"
"github.com/ocomsoft/HxComponents/components"
)
func main() {
registry := components.NewRegistry()
components.Register(registry, "search", mycomponent.Search)
components.Register(registry, "login", mycomponent.Login)
router := chi.NewRouter()
// Mount all components with wildcard pattern
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)
http.ListenAndServe(":8080", router)
}The Handler method extracts the component name from the last segment of the URL path:
/component/search→ component name:search/api/login→ component name:login/component/profile→ component name:profile
For explicit control over each component URL, use HandlerFor():
router := chi.NewRouter()
// Mount components at specific URLs
router.Get("/search", registry.HandlerFor("search"))
router.Post("/search", registry.HandlerFor("search"))
router.Get("/login", registry.HandlerFor("login"))
router.Post("/login", registry.HandlerFor("login"))package main
import (
"github.com/gorilla/mux"
"github.com/ocomsoft/HxComponents/components"
)
func main() {
registry := components.NewRegistry()
components.Register(registry, "search", mycomponent.Search)
components.Register(registry, "login", mycomponent.Login)
router := mux.NewRouter()
// Option 1: Wildcard pattern (extracts component name from URL)
router.PathPrefix("/component/").HandlerFunc(registry.Handler).Methods("GET", "POST")
// Option 2: Specific URLs (explicit component names)
router.HandleFunc("/search", registry.HandlerFor("search")).Methods("GET", "POST")
router.HandleFunc("/login", registry.HandlerFor("login")).Methods("GET", "POST")
http.ListenAndServe(":8080", router)
}No router needed - use the standard library directly:
package main
import (
"net/http"
"github.com/ocomsoft/HxComponents/components"
)
func main() {
registry := components.NewRegistry()
components.Register(registry, "search", mycomponent.Search)
components.Register(registry, "login", mycomponent.Login)
// Option 1: Wildcard pattern (extracts component name from URL)
http.HandleFunc("/component/", registry.Handler)
// Option 2: Specific URLs (explicit component names)
http.HandleFunc("/search", registry.HandlerFor("search"))
http.HandleFunc("/login", registry.HandlerFor("login"))
http.ListenAndServe(":8080", nil)
}You can easily mix component handlers with your custom route handlers:
router := chi.NewRouter()
// Custom handlers
router.Get("/", homePageHandler)
router.Get("/about", aboutPageHandler)
// Component handlers with wildcard
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)
// Or specific component URLs
router.Get("/search", registry.HandlerFor("search"))
router.Post("/search", registry.HandlerFor("search"))Capture HTMX request headers and HTTP method by implementing optional interfaces:
type SearchComponent struct {
Query string `form:"q"`
IsBoosted bool `json:"-"`
CurrentURL string `json:"-"`
Method string `json:"-"` // "GET" or "POST"
}
func (s *SearchComponent) SetHxBoosted(v bool) { s.IsBoosted = v }
func (s *SearchComponent) SetHxCurrentURL(v string) { s.CurrentURL = v }
func (s *SearchComponent) SetHttpMethod(v string) { s.Method = v }The HttpMethod interface is useful for varying component behavior based on GET vs POST:
func (s *SearchComponent) Process() error {
if s.Method == "GET" {
// Load default search results
s.Query = s.Query // Keep query from URL params
} else {
// POST - user submitted form
// Validate input, log search, etc.
}
return nil
}Available Request Interfaces:
| Interface | Header/Source | Type |
|---|---|---|
HxBoosted |
HX-Boosted | bool |
HxRequest |
HX-Request | bool |
HxCurrentURL |
HX-Current-URL | string |
HxPrompt |
HX-Prompt | string |
HxTarget |
HX-Target | string |
HxTrigger |
HX-Trigger | string |
HxTriggerName |
HX-Trigger-Name | string |
HttpMethod |
HTTP Method (GET/POST) | string |
Set HTMX response headers by implementing getter interfaces:
type LoginComponent struct {
Username string `form:"username"`
Password string `form:"password"`
RedirectTo string `json:"-"`
}
func (f *LoginComponent) GetHxRedirect() string {
return f.RedirectTo
}Available Response Interfaces:
| Interface | Header | Type |
|---|---|---|
HxLocationResponse |
HX-Location | string |
HxPushUrlResponse |
HX-Push-Url | string |
HxRedirectResponse |
HX-Redirect | string |
HxRefreshResponse |
HX-Refresh | bool |
HxReplaceUrlResponse |
HX-Replace-Url | string |
HxReswapResponse |
HX-Reswap | string |
HxRetargetResponse |
HX-Retarget | string |
HxReselectResponse |
HX-Reselect | string |
HxTriggerResponse |
HX-Trigger | string |
HxTriggerAfterSettleResponse |
HX-Trigger-After-Settle | string |
HxTriggerAfterSwapResponse |
HX-Trigger-After-Swap | string |
The registry supports both GET and POST requests for maximum flexibility:
POST is the standard HTMX pattern for form submissions:
<form hx-post="/component/search" hx-target="#results">
<input type="text" name="q" value="htmx" />
<button>Search</button>
</form>Form data is sent in the request body and parsed into the component struct.
GET requests are useful for loading components with initial state or query parameters:
<!-- Load on page load -->
<div hx-get="/component/search?q=golang&limit=5"
hx-trigger="load"
hx-target="this">
</div>
<!-- Load on click -->
<button hx-get="/component/search?q=htmx&limit=10"
hx-target="#results">
Load Popular Searches
</button>
<!-- Preload with hx-boost -->
<a href="/component/search?q=go&limit=20"
hx-boost="true"
hx-target="#results">
Go Results
</a>Query parameters are parsed into the component struct, just like POST form data.
Use Cases for GET:
- Loading components with default/initial values
- Deep-linking to specific component states
- Shareable URLs with query parameters
- Server-side rendering of initial state
- Progressive enhancement patterns
Components can implement the Processor interface to perform business logic, validation, or data transformation after form decoding but before rendering.
type Processor interface {
Process() error
}The registry automatically calls Process() if your component implements this interface:
type LoginComponent struct {
Username string `form:"username"`
Password string `form:"password"`
RedirectTo string `json:"-"`
Error string `json:"-"`
}
// Implement Processor interface
func (f *LoginComponent) Process() error {
if f.Username == "demo" && f.Password == "password" {
f.RedirectTo = "/dashboard" // This will trigger HX-Redirect
return nil
}
f.Error = "Invalid credentials"
return nil
}
// Implement response header interface
func (f *LoginComponent) GetHxRedirect() string {
return f.RedirectTo
}Processing Flow:
- Form data decoded into struct
- Request headers applied (HX-Boosted, HX-Request, etc.)
Process()called (if interface implemented)- Response headers applied (HX-Redirect, HX-Trigger, etc.)
- Component rendered
When to Use:
- Form validation
- Authentication/authorization
- Database operations
- Setting conditional response headers
- Business logic that affects rendering
Error Handling:
- Return
erroronly for unexpected system failures - Store validation errors in struct fields for rendering
- Example:
f.Error = "Invalid input"instead ofreturn err
This example is now simplified with the Processor interface:
type LoginComponent struct {
Username string `form:"username"`
Password string `form:"password"`
RedirectTo string `json:"-"`
Error string `json:"-"`
}
// Processor interface - called automatically by registry
func (f *LoginComponent) Process() error {
if f.Username == "demo" && f.Password == "password" {
f.RedirectTo = "/dashboard"
return nil
}
f.Error = "Invalid credentials"
return nil
}
// Response header interface
func (f *LoginComponent) GetHxRedirect() string {
return f.RedirectTo
}Register (simplified):
components.Register(registry, "login", Login)The registry automatically calls Process() before rendering!
type ProfileComponent struct {
Name string `form:"name"`
Email string `form:"email"`
Tags []string `form:"tags"`
}HTML:
<form hx-post="/component/profile" hx-target="#result">
<input name="name" value="John" />
<input name="email" value="john@example.com" />
<input name="tags" value="developer" />
<input name="tags" value="golang" />
<button>Update</button>
</form>The examples/ directory contains a complete demo application:
cd examples
go run main.goThen open http://localhost:8080 in your browser.
The demo includes:
- Search Component - Demonstrates request header capture
- Login Component - Demonstrates response headers (redirects)
- Profile Component - Demonstrates complex form data with arrays
1. HTTP POST /component/{name}
2. Registry finds component entry
3. Parse form data into typed struct
4. Apply HTMX request headers (if interfaces implemented)
5. Execute component logic (optional)
6. Apply HTMX response headers (if interfaces implemented)
7. Render templ component
8. Return HTML response
The registry uses Go generics to maintain type safety:
func Register[T any](r *Registry, name string, render func(T) templ.Component)This ensures:
- ✅ Compile-time type checking
- ✅ No runtime type assertion errors
- ✅ IDE autocomplete and refactoring support
Creates a new component registry.
Registers a component type that implements templ.Component.
Parameters:
T- Component type (must be a pointer type that implements templ.Component)name- Component name used to identify the component
Example:
components.Register[*mycomponent.SearchComponent](registry, "search")Requirements:
- The component type must implement
templ.Componentinterface - The component must have a
Render(ctx context.Context, w io.Writer) errormethod
Extracts the component name from the URL path and renders the component. The component name is extracted from the last segment of the URL path after the last slash. This allows for wildcard routing patterns.
Example:
// With chi
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)
// With gorilla/mux
router.PathPrefix("/component/").HandlerFunc(registry.Handler).Methods("GET", "POST")
// With net/http
http.HandleFunc("/component/", registry.Handler)URL to Component Name Mapping:
/component/search→search/api/login→login/component/profile→profile
Returns an http.HandlerFunc for rendering a specific component. Use this when you want explicit control over component URLs.
Parameters:
componentName- The name of the registered component
Example:
http.HandleFunc("/search", registry.HandlerFor("search"))
router.Get("/search", registry.HandlerFor("search"))Sets a custom error handler for rendering error responses.
Example:
registry.SetErrorHandler(func(w http.ResponseWriter, req *http.Request, title string, message string, code int) {
// Custom error rendering with your own template
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(code)
MyErrorTemplate(title, message, code).Render(req.Context(), w)
})The registry uses Go's standard log/slog for structured logging. Configure your logger before starting the server:
import "log/slog"
// Set log level to debug to see component rendering logs
slog.SetLogLoggerLevel(slog.LevelDebug)
// Or use a custom handler
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))Logged Events:
Debug- Component rendering started and completedWarn- Method not allowed, component not foundError- Form parse/decode errors, process errors, render errors
The registry provides a customizable error handler for rendering error responses. By default, it uses the built-in ErrorComponent template.
Default Error Component:
ErrorComponent(title string, message string, code int)Custom Error Handler:
type ErrorHandler func(w http.ResponseWriter, req *http.Request, title string, message string, code int)
registry := components.NewRegistry()
registry.SetErrorHandler(myCustomErrorHandler)// Good
components.Register(registry, "user-search", SearchComponent)
components.Register(registry, "profile-edit", ProfileComponent)
// Avoid
components.Register(registry, "comp1", SearchComponent)// Good - logic in method
func (f *LoginComponent) ProcessLogin() error { ... }
components.Register(registry, "login", func(data LoginComponent) templ.Component {
data.ProcessLogin()
return LoginComponent(data)
})
// Avoid - logic in templatetype MyForm struct {
UserInput string `form:"input"`
RedirectTo string `json:"-"` // Won't be serialized
IsBoosted bool `json:"-"` // Internal state
}// Good - only implement what you need
type SimpleForm struct {
Query string `form:"q"`
}
// Avoid - implementing unused interfacesProblem: Struct fields remain empty after form submission.
Solution: Ensure form tags match HTML input names exactly:
type Form struct {
Email string `form:"email"` // Must match <input name="email">
}Problem: HTMX headers not being captured or set.
Solution: Verify interface implementation:
// Implement pointer receiver
func (f *MyForm) SetHxBoosted(v bool) {
f.IsBoosted = v
}Problem: 404 error when posting to component.
Solution: Check component name matches registration:
components.Register(registry, "my-component", ...)
// POST to: /component/my-componentContributions are welcome! Please feel free to submit a Pull Request.
MIT License - See LICENSE file for details.
Built with ❤️ for the Go + HTMX community