Skip to content
gohacks.dev
Web

Custom 404 and Error Pages in Go Servers Done Right

Replace the plain text 404 and 405 responses of http.ServeMux with your own pages or JSON, using a catch-all route and a small response wrapper.

4 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

http.ServeMux answers an unknown path with two words and a status: 404 page not found. That is fine for an API and wrong for a website, and there is no NotFoundHandler field to set. The way to customise it is built from two plain tools: the / pattern, which matches everything no more specific route claims, and a wrapper around ResponseWriter for the replies the mux writes itself.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func notFound(w http.ResponseWriter, r *http.Request) {
	if strings.HasPrefix(r.URL.Path, "/api/") {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)
		fmt.Fprint(w, `{"error":"not_found"}`)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusNotFound) // keep the real status; a friendly 200 is a soft 404
	fmt.Fprint(w, "<h1>Page not found</h1><p><a href=\"/\">Back to the home page</a></p>")
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "home") })
	mux.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "about") })
	mux.HandleFunc("/", notFound) // everything the patterns above do not claim

	plain := http.NewServeMux()
	plain.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "about") })

	show := func(label string, h http.Handler, method, path string) {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
		fmt.Printf("%-22s %-4s %-12s -> %d %q allow=%q\n", label, method, path, rec.Code,
			strings.TrimSpace(rec.Body.String()), rec.Header().Get("Allow"))
	}
	show("bare mux", plain, "GET", "/nope")
	show("bare mux", plain, "POST", "/about")
	show("with catch-all", mux, "GET", "/nope")
	show("with catch-all", mux, "GET", "/api/users/9")
	show("with catch-all", mux, "GET", "/")
	show("with catch-all, wrong method", mux, "POST", "/about")
}
Text
bare mux               GET  /nope        -> 404 "404 page not found" allow=""
bare mux               POST /about       -> 405 "Method Not Allowed" allow="GET, HEAD"
with catch-all         GET  /nope        -> 404 "<h1>Page not found</h1><p><a href=\"/\">Back to the home page</a></p>" allow=""
with catch-all         GET  /api/users/9 -> 404 "{\"error\":\"not_found\"}" allow=""
with catch-all         GET  /            -> 200 "home" allow=""
with catch-all, wrong method POST /about       -> 404 "<h1>Page not found</h1><p><a href=\"/\">Back to the home page</a></p>" allow=""

The first two rows show the defaults: a bare 404, and for a path that exists with the wrong method a 405 and an Allow header. With the catch-all, unknown paths get the page or the JSON body, chosen by prefix, and both keep the 404 status. That status matters: a not-found page that answers 200 OK is a soft 404, and search engines will index it as a real page.

Notice the home route is GET /{$}, not /. In Go 1.22 patterns, / matches every path, so using it for the home page would swallow the fallback; {$} pins it to exactly the root.

The last row is the catch: with / registered, a POST to /about no longer produces 405, because the catch-all now matches that request and answers 404. Registering a fallback changes the mux's own method-mismatch behavior, since / is a valid match for any method. If you want the 405 back, register POST /about handlers explicitly, or answer 405 inside notFound when the path is one you know. To restyle replies the mux writes, wrap the ResponseWriter as shown in the response writer wrapper article, intercept WriteHeader(404) or 405, and write your own body. A server error page works the same way with the recover middleware. The mux behavior is documented in the net/http package.

Two practical notes from using this. First, log the 404s. A spike in not-found paths is either a broken link on your own site, a deploy that dropped a route, or a scanner probing for /wp-login.php, and all three are worth knowing about. Count them by path prefix rather than by exact path so the scanners do not blow up your metrics. Second, give the error page a way out: a link home, and the search box if you have one. Generic pages that apologise and stop are the ones people close.

For an API, skip the HTML and return a machine readable body, ideally the problem-details shape described in error responses for APIs, so a client can tell "this route does not exist" from "this record does not exist". That distinction matters more than it looks: a 404 from the router means the URL is wrong, while a 404 from a handler means the thing you asked for is gone, and clients that retry or cache differently on each need to know which one they got. A tiny extra header or an error code in the body is enough.

Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.