| title | Page Response Patterns |
|---|---|
| slug | /supported-flows |
| sidebar_position | 5 |
There are four main shapes — choose based on what the page does. The first renders declaratively (Props method + Page method); the other three are handler methods (ServeHTTP).
| Shape | Entry | When to use |
|---|---|---|
| Renders a page | Props(...) + Page(props) |
Standard pages, HTMX partials — the primary pattern |
| Returns a partial | ServeHTTP(w, r, deps...) error |
Form actions that mutate then refresh a region |
| Redirects | ServeHTTP(w, r, deps...) error |
Post-action navigation |
| Serves JSON | ServeHTTP(w, r, deps...) (no error return) |
API endpoints |
type index struct {
add `route:"POST /add Add"`
}
func (p index) Props(r *http.Request, target structpages.RenderTarget) ([]Todo, error) {
switch {
case target.Is(p.TodoList):
return getActiveTodos(), nil // partial update — load only what it needs
default:
return getAllTodos(), nil // full page
}
}
templ (p index) Page(todos []Todo) {
@html() {
<form hx-post={ structpages.URLFor(ctx, add{}) }
hx-target={ structpages.IDTarget(ctx, index.TodoList) }>
<input name="text" />
<button>Add</button>
</form>
<div id={ structpages.ID(ctx, index.TodoList) }>
@p.TodoList(todos)
</div>
}
}
templ (p index) TodoList(todos []Todo) {
for _, todo := range todos {
<div>{ todo.Text }</div>
}
}The Props method runs with a RenderTarget injected, so it knows which page component will render and loads only that region's data. Initial loads render Page; HTMX requests targeting index.TodoList's id render just the partial.
Real pages often need a props struct with many fields while individual page components take only a subset:
type IndexProps struct {
Users []User
Picklists []Picklist
Search string
TotalCount int
}
func (p index) Props(r *http.Request, target structpages.RenderTarget) (IndexProps, error) {
switch {
case target.Is(p.UserList):
users, err := searchUsers(r.URL.Query().Get("q"))
if err != nil {
return IndexProps{}, err
}
return IndexProps{}, structpages.RenderComponent(p.UserList(users))
default: // full page
users, err := getAllUsers()
if err != nil {
return IndexProps{}, err
}
picklists, err := getPicklists()
if err != nil {
return IndexProps{}, err
}
return IndexProps{
Users: users,
Picklists: picklists,
Search: r.URL.Query().Get("q"),
TotalCount: len(users),
}, nil
}
}p.UserList(users) is a normal Go call — compile-time checked — handed to RenderComponent as the response. Two principles generalize from this:
- Partials get partial data, never the full props struct. When
target.Is(p.UserList)matches, you build just that region's data; theIndexProps{}returned alongsideRenderComponentis ignored. - The default case falls back to full props, not empty props — browser navigation, boosted swaps, and unrecognised targets all need the whole page.
On larger pages the props struct is typically composed of per-pane sub-structs, with helper props methods feeding both the partial branches and a shared fullProps — see the worked example in HTMX Integration.
The most common HTMX form action — mutate state, respond with the refreshed region:
type add struct{}
func (a add) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
text := r.FormValue("text")
if text != "" {
if err := addTodo(text); err != nil {
return err
}
}
todos, err := getActiveTodos()
if err != nil {
return err
}
return structpages.RenderComponent(index{}.TodoList(todos))
}Prefer passing the component instance. index{}.TodoList(todos) is a normal Go call — the compiler checks argument types and counts — and page structs are stateless, so a zero-value receiver constructs a sibling page's component just as well as your own. Passing the component method itself is also supported — RenderComponent(index.TodoList, todos) — and the framework will find the page, DI-inject any injectable parameters, and fill the rest from the args; but those checks happen at runtime, so reserve it for components whose parameters genuinely need framework DI (see HTMX Integration).
Don't call http.Redirect directly in an HTMX app — during an HTMX request the XHR follows the 3xx and swaps the redirect target's body into the partial's swap target. Return a control-flow signal instead and let the global error handler send the right mechanism per request kind (HX-Location for HTMX, 303 otherwise):
func (p submitForm) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error {
id, err := store.Save(r.Context(), r.FormValue("name"))
if err != nil {
return err
}
url, err := structpages.URLFor(r.Context(), detailPage{}, map[string]any{"itemId": id})
if err != nil {
return err
}
return Redirect{To: url}
}The Redirect type and the error-handler wiring are covered in Error Handling.
API endpoints use the no-error form so writes go straight to the wire (unbuffered) and the framework's HTML error handler stays out of it. You own the response — including errors, which are JSON like everything else:
type trackTime struct{}
func (p trackTime) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) {
var body trackTimeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request")
return
}
if err := store.UpdateTime(r.Context(), body); err != nil {
writeJSONError(w, http.StatusInternalServerError, "update failed")
return
}
w.WriteHeader(http.StatusOK)
}See Error Handling for writeJSONError and why http.Error is the wrong tool here.
Four signatures are supported. The DI forms take typed params (matched by type, any order) — there is no variadic deps ...any:
func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) // standard http.Handler, unbuffered
func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request) error // buffered; error → WithErrorHandler
func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) // DI, no return, unbuffered
func (p T) ServeHTTP(w http.ResponseWriter, r *http.Request, store *Store) error // DI, bufferedThe error-returning forms run against a buffered writer so the error handler can discard partial output and render a clean error page. That has consequences — never write w then return an error. The full rules are in Error Handling.
- HTMX Integration —
RenderTarget, partial selection, the id loop. - Error Handling — buffering rules, typed errors, redirects, JSON.
- examples/todo — complete working example.