Subdomain Routing With the Standard Mux in Go Apps
Route api, admin and www hosts to different handlers with one http.ServeMux by putting the host in the pattern, and learn how it treats ports and case.

When one binary serves www, api and admin, the first instinct is an if r.Host == ... ladder at the top of a handler. The standard mux already does this job, and since Go 1.22 the pattern syntax says so in the documentation: a pattern may begin with a host name, written right before the path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The program below registers two host patterns and a default, then sends requests with awkward hosts. Between the client and the mux sits a three-line wrapper that lowercases the host; the run goes once without it and once with it.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func newMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("api.example.test/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "api") })
mux.HandleFunc("GET admin.example.test/users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "admin user "+r.PathValue("id"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "default site") })
return mux
}
// normalizeHost lowercases the host and drops one trailing dot, which the mux does not do.
func normalizeHost(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Host = strings.TrimSuffix(strings.ToLower(r.Host), ".")
next.ServeHTTP(w, r)
})
}
func main() {
cases := []struct{ method, host, path string }{
{"GET", "api.example.test", "/v1/ping"},
{"GET", "api.example.test:8443", "/v1/ping"},
{"GET", "API.Example.Test", "/v1/ping"},
{"GET", "api.example.test.", "/v1/ping"},
{"GET", "admin.example.test", "/users/7"},
{"POST", "admin.example.test", "/users/7"},
{"GET", "admin.example.test", "/dashboard"},
{"GET", "tenant1.example.test", "/"},
}
for _, h := range []struct {
label string
handler http.Handler
}{{"mux alone", newMux()}, {"with normalizeHost", normalizeHost(newMux())}} {
fmt.Println(h.label)
for _, c := range cases {
req := httptest.NewRequest(c.method, "http://placeholder"+c.path, nil)
req.Host = c.host
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
fmt.Printf(" %-4s %-24s %-10s -> %d %s\n", c.method, c.host, c.path, rec.Code, rec.Body.String())
}
}
}mux alone
GET api.example.test /v1/ping -> 200 api
GET api.example.test:8443 /v1/ping -> 200 api
GET API.Example.Test /v1/ping -> 200 default site
GET api.example.test. /v1/ping -> 200 default site
GET admin.example.test /users/7 -> 200 admin user 7
POST admin.example.test /users/7 -> 200 default site
GET admin.example.test /dashboard -> 200 default site
GET tenant1.example.test / -> 200 default site
with normalizeHost
GET api.example.test /v1/ping -> 200 api
GET api.example.test:8443 /v1/ping -> 200 api
GET API.Example.Test /v1/ping -> 200 api
GET api.example.test. /v1/ping -> 200 api
GET admin.example.test /users/7 -> 200 admin user 7
POST admin.example.test /users/7 -> 200 default site
GET admin.example.test /dashboard -> 200 default site
GET tenant1.example.test / -> 200 default siteThree behaviors stand out in the first block of output. The port is ignored: api.example.test:8443 reached the API handler, which is what you want when the same binary runs on a development port. Case is not ignored: API.Example.Test fell through to the default site, even though DNS names are case insensitive and browsers lowercase them, so this only bites with hand-built requests and the odd client, but it bites silently, and a request meant for api lands on the public site. A trailing dot (api.example.test., legal in DNS) misses the same way. The second block shows the wrapper fixing both. Because it is one function, I put it first in the chain on any server that routes on hosts.
The POST row is another thing to know. The admin pattern says GET, so a POST to /users/7 does not match it, but the default pattern / has no method restriction, so the request is served by the default site with a 200, not rejected with a 405. The mux only answers 405 when no pattern matches the path at all. If you want the admin host to refuse everything else, register admin.example.test/ as its own catch-all, which returns 404, as covered in the custom 404 tip. The rules for method patterns and precedence are in the Go 1.22 mux article.
One deployment detail decides whether any of this works in production: the mux sees the Host your server received. Behind a reverse proxy that is often the internal service name unless the proxy forwards the original (nginx needs proxy_set_header Host $host;, and Go's own ReverseProxy needs pr.Out.Host = pr.In.Host). Log r.Host once, on a real request, before debugging the patterns themselves. Two limits remain, both worth knowing before you commit to this design. Patterns need a literal host, so {tenant}.example.test/ is invalid, and per-customer subdomains need the resolver from the multi tenant routing article. And for local work, *.localhost resolves to the loopback address in current browsers, so http://api.localhost:8080 works without editing /etc/hosts. The pattern grammar is defined in the net/http documentation.


