| title | Error Handling |
|---|---|
| slug | /error-handling |
| sidebar_position | 9 |
The error-returning forms of ServeHTTP — and every Props method — run against a buffered response writer. On a non-nil error the buffer is discarded and the error goes to the WithErrorHandler callback, which renders a clean error page even if the handler had already written partial output. Everything in this guide follows from that one mechanism.
- Never call
http.Error(or writew) in an error-returning handler or in Props. If you write thenreturn err, the write is discarded; if you write thenreturn nil, you bypass the error handler. Just return the error. - For a specific status code, return a typed error that the global handler unwraps with
errors.As. Plain errors fall through to a logged 500. - API/JSON endpoints use the no-error
ServeHTTPform — directwwrites are correct there because you own the status code and skip the buffering wrapper. Write JSON error bodies, nothttp.Error. - For streaming (SSE), flush with
http.NewResponseController(w)— it works from eitherServeHTTPform and is the only way to guarantee unbuffered delivery through middleware.
Define one error type that carries the status and message; the global handler renders it:
type ErrorWithStatus struct {
Status int
Title string
Message string
}
func (e ErrorWithStatus) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Title, e.Message) }func (p detailPage) Props(r *http.Request, store *Store) (DetailProps, error) {
item, err := store.Load(r.Context(), r.PathValue("itemId"))
switch {
case errors.Is(err, ErrNotFound):
return DetailProps{}, ErrorWithStatus{Status: http.StatusNotFound, Title: "Not found", Message: "no such item"}
case err != nil:
return DetailProps{}, fmt.Errorf("detail: load: %w", err) // plain error → logged 500
}
return DetailProps{Item: item}, nil
}errors.As unwraps, so fmt.Errorf("...: %w", ErrorWithStatus{...}) still resolves to its status.
Don't call http.Redirect from a handler 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 signal instead; the error handler sends HX-Location for HTMX (ajax navigation, like a boosted link) and a 303 otherwise:
// Redirect is control flow, not a real error — it implements error only to
// ride the error-return path, which unwinds the render flow without writing
// the ResponseWriter directly.
type Redirect struct{ To string }
func (Redirect) Error() string { return "redirect" }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}
}Use HX-Redirect instead of HX-Location only when the destination genuinely needs a full browser load — a non-htmx endpoint, or a page with different <head> content/scripts.
Wired once at Mount, it owns every error response — typed statuses, redirects, cancellations, and the logged-500 fallback:
structpages.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, context.Canceled) || r.Context().Err() != nil {
w.WriteHeader(499) // client closed request — expected, don't log as error
return
}
var redir Redirect
if errors.As(err, &redir) {
if r.Header.Get("HX-Request") == "true" {
// Ajax navigation, like a boosted link. The status must stay 2xx:
// htmx does not process response headers on 3xx responses.
w.Header().Set("HX-Location", redir.To)
return
}
http.Redirect(w, r, redir.To, http.StatusSeeOther)
return
}
status, title, message := http.StatusInternalServerError, "Server error", err.Error()
var se ErrorWithStatus
if errors.As(err, &se) {
status, title, message = se.Status, se.Title, se.Message
} else {
slog.Error("unhandled error rendering page", "error", err, "path", r.URL.Path)
}
// One place that knows how to render: HTMX-aware retarget, full layout vs bare page.
renderHTTPError(w, r, status, title, message)
})For endpoints that serve JSON, use the no-return ServeHTTP(w, r, deps...) signature. It is unbuffered, the HTML error handler is never invoked, and you own the response — including errors, which are JSON like everything else. Don't reach for http.Error; its text/plain body is the wrong shape for an API client:
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)
}
// The API's single error shape, defined once:
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}Picking the no-return form is not enough to guarantee writes reach the client immediately — w may still be wrapped by middleware. Use http.ResponseController, which walks the Unwrap() chain to find a flusher:
func (p progress) ServeHTTP(w http.ResponseWriter, r *http.Request, jobs *JobService) error {
w.Header().Set("Content-Type", "text/event-stream")
rc := http.NewResponseController(w)
for update := range jobs.Progress(r.Context()) {
fmt.Fprintf(w, "event: progress\ndata: %s\n\n", update)
if err := rc.Flush(); err != nil {
return nil // client gone
}
}
return nil
}This works from either ServeHTTP form — the buffered wrapper implements FlushError() and Unwrap(). Once you've started flushing, a non-nil error can no longer produce a clean error page (bytes are on the wire) — send an event: error SSE frame instead and return nil.
| Handler does… | ServeHTTP signature |
Errors via |
|---|---|---|
| Renders HTML / HTMX partial | (w, r, deps...) error |
return ErrorWithStatus{…} / return err |
| Redirects | (w, r, deps...) error |
return Redirect{To: …} |
| Serves JSON / API | (w, r, deps...) (no return) |
write w directly with a JSON error body |
| Streams (SSE, progress) | either form + http.NewResponseController |
SSE event: error frame, then return nil |
Props methods always follow the first row — they are buffered and their errors flow to WithErrorHandler.
If a Props method writes the response itself (rare — prefer the Redirect signal), return structpages.ErrSkipPageRender to skip rendering. Only the Props error path checks this sentinel; returning it from ServeHTTP does nothing special.