{}

Errors

Three kinds of errors come up in a real BESPA app: a route that matched but the resource doesn’t exist (404), a handler that crashed or returned an unexpected error (500), and a domain error that the user can act on (“email already exists”). The first two render dedicated error pages. The third is part of the form / persist flow — see Forms & validation for that side of the story.

404 — not found

A handler that decides the resource doesn’t exist writes the status and renders a regular BESPA page:

// store is your data layer; ErrNotFound is its sentinel error.
func handleOrder(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    order, err := store.Find(id)
    if errors.Is(err, ErrNotFound) {
        renderNotFound(w, r, "Order "+id)
        return
    }
    // ... render the order page ...
}

func renderNotFound(w http.ResponseWriter, r *http.Request, what string) {
    w.WriteHeader(http.StatusNotFound)
    wf.Page().Add(
        wf.AppBar("Not found"),
        wf.HeadlineLarge("404 — Not found"),
        wf.Markdown(what, " doesn't exist or has been removed."),
        wf.ButtonText("").Add("Back").WithHrefBack(),
    ).Draw(w, r)
}

The WriteHeader(404) must come before the page’s first byte of body is written. Calling Draw after WriteHeader is the correct order — WriteHeader writes the status line; Draw writes the body.

For URLs that match no registered handler at all, the stdlib mux automatically returns a plain 404. To replace it with a friendly BESPA page, route / to a catch-all handler that checks the path:

// In main():
mux.HandleFunc("/", handleRoot)   // matches everything not otherwise routed

func handleRoot(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        renderNotFound(w, r, "The page")
        return
    }
    // ... real home page ...
}

500 — panic recovery

A panic inside a handler crashes the goroutine unless something recovers. Add a top-level recover middleware that logs the stack and renders a 500 page:

func recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic in %s %s: %v\n%s",
                    r.Method, r.URL.Path, rec, debug.Stack())
                renderServerError(w, r)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func renderServerError(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusInternalServerError)
    wf.Page().Add(
        wf.AppBar("Something went wrong"),
        wf.HeadlineLarge("500 — Internal error"),
        wf.Markdown(
            "An error occurred handling your request. Try again, or ",
            "[contact support](/contact) if it keeps happening.",
        ),
    ).Draw(w, r)
}

// In main():
http.ListenAndServe(":8080", recoverPanic(mux))

Log the full stack to your observability system; render only a friendly message to the user. Never echo recover()’s value into the HTML — it’s untrusted, and often discloses internal paths or data.

Form & persist errors

For errors that are part of normal flow — validation, duplicate records, transient downstream failures — don’t redirect to an error page. Re-render the form with a Snackbar (or attach an InfoBubble to the offending field):

// On error inside a form handler, re-render with a Snackbar instead of
// redirecting. The URL still carries the user's input, so the form
// repopulates.

if form.ReadyToCommit(r) {
    if err := persist(values); err != nil {
        page := wf.Page().Add(
            wf.AppBar("New person"),
            wf.Snackbar().
                WithMessage("Couldn't save: " + friendlyMessage(err)).
                ShowIf(true),
            form,
        )
        page.Draw(w, r)
        return
    }
    wf.RedirectBack(w, r)
}

Because no redirect happens, the URL still carries the submitted values; the inputs come back populated and the user can fix the cause and re-submit. Full pattern in Forms & validation → Persist errors.

Errors inside an embedded page

When a modal or side panel embeds a handler via EmbedHandler, the embedded handler’s response is what gets rendered inside the overlay. If that handler returns a 404 or 500, the user sees the error page inside the modal — which is usually fine, but sometimes you’d rather close the modal and surface the message on the parent:

// mux is your *http.ServeMux (see Handlers & routing).
// In a modal that embeds /orders/{id}/edit:
wf.Modal("modal").Add(
    wf.EmbedHandler(mux.ServeHTTP, r, "GET",
        wf.StateOf(r).Get("modal")+"?_back=^?modal=", nil),
),

// The embedded handler can render its own error page — the framework just
// reads its <body> and drops it into the modal. A 404 inside the modal
// shows up as a "Not found" page rendered inside the modal frame; the
// parent page stays open underneath, and the user can dismiss the modal.

// If you want the modal to close automatically on error, redirect to the
// parent with the error in a state variable the parent reacts to:
wf.Redirect(w, r, "^?modal=&error=order-not-found")

EmbedHandler decompresses Content-Encoding automatically and passes through the response body — the wire format of the embedded error page is the same as any other BESPA response.

Logging vs surfacing

Two principles worth naming:

See also

Forms & validation → Persist errors — the re-render-with-Snackbar idiom for domain errors.

Handlers & routing — where the recover middleware sits in the middleware stack.

Modals & side panels — what an embedded handler’s errors look like to the user.