Why does your Go HTTP server need proper routing and middleware?
You started with http.HandleFunc and everything worked. Then came endpoints with parameters, authentication, logging, centralized error handling. The code turned into a mess of if-else chains, duplicate logic, and copy-paste. The problem is not Go: it's using the right tool the wrong way.
We at Meteora Web have been using Go for years on custom backend platforms. We started simple too — then we realized a well-structured HTTP server is the difference between a project that stays maintainable and one that becomes a cost every time you need to add a route.
This guide shows you how to build a routing and middleware system that scales, using only the Go standard library (net/http). No external frameworks, no unnecessary dependencies. Just code you understand and control.
How does the net/http multiplexer work in Go?
The heart of every Go HTTP server is http.ServeMux, a multiplexer that maps URL patterns to handlers. Until Go 1.21, patterns were fixed: "/api/users" or "/api/" for prefixes. No dynamic parameters like :id.
With Go 1.22 (and later), the multiplexer supports patterns with parameters and HTTP methods. This changes everything: you can define routes like GET /users/{id} directly with the standard library, without external packages.
Sponsored Protocol
Basic example: ServeMux with modern patterns
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Pattern with method and dynamic parameter
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "User ID: %s", id)
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "User created")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}Note: r.PathValue() is available from Go 1.22. If you're on older versions, you need packages like gorilla/mux or chi — but we recommend upgrading Go and using the standard library.
How to implement advanced routing with pattern matching in Go 1.22+?
The new ServeMux supports:
- Explicit HTTP methods:
GET /resource,POST /resource,PUT /resource/{id}. - Dynamic parameters:
{id},{slug},{path*}to match multiple segments. - Priority based on specificity: more specific patterns win over generic ones.
Example: routing with multiple methods and parameters
mux.HandleFunc("GET /posts", listPosts)
mux.HandleFunc("POST /posts", createPost)
mux.HandleFunc("GET /posts/{slug}", getPost)
mux.HandleFunc("PUT /posts/{slug}", updatePost)
mux.HandleFunc("DELETE /posts/{slug}", deletePost)Notice: you no longer need manual r.Method checks. The mux itself handles 405 Method Not Allowed if the method doesn't match.
Sponsored Protocol
Handling wildcard asterisk
Use {path*} to capture the rest of the path:
mux.HandleFunc("GET /files/{path*}", func(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
fmt.Fprintf(w, "Requested path: %s", path)
})How to create composable middleware for authentication, logging and recovery?
A middleware in Go is a function that takes an http.Handler and returns another http.Handler. The standard signature is:
type Middleware func(http.Handler) http.HandlerYou can compose middleware as nested functions. Here are the three most useful for a serious backend.
Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}Authentication middleware (API Key)
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key != "super-secret-key" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}Recovery middleware (panic catching)
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Composing middleware
Use a helper function to apply multiple middleware in sequence:
Sponsored Protocol
func chainMiddleware(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}Then apply to your mux:
mux := http.NewServeMux()
mux.Handle("GET /protected", chainMiddleware(
http.HandlerFunc(protectedHandler),
recoveryMiddleware,
authMiddleware,
loggingMiddleware,
))Or, if you want global middleware for all routes, wrap the mux:
Sponsored Protocol
finalHandler := chainMiddleware(mux, recoveryMiddleware, loggingMiddleware)
log.Fatal(http.ListenAndServe(":8080", finalHandler))How to handle context and decorator pattern for complex handlers?
Often you need to pass data (logged-in user, DB connection) from middleware to handler. In Go, use context.Context via r.Context().
Middleware enriching context
func contextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// example: user extracted from token
user := &User{ID: 42, Name: "Mario"}
ctx := context.WithValue(r.Context(), "user", user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}In the handler, retrieve the value:
func protectedHandler(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
fmt.Fprintf(w, "Welcome %s", user.Name)
}Warning: context is not a dumping ground for arbitrary data. Use it only for request-scoped values (auth, trace IDs, etc.).
What mistakes to avoid when building an HTTP server in Go with net/http?
We've seen plenty in projects that come to us. Here are the most common:
- Not closing the body: always use
defer r.Body.Close()in every handler that reads the body. - No timeouts: a server without timeouts will die. Set
http.Server{ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second}. - Global middleware without exclusions: sometimes you need to bypass auth on health checks. Create an explicit route or use an exclusion pattern.
- Ignoring ListenAndServe errors:
http.ListenAndServereturns an error if the server crashes. Log it or panic in development.
What to do next
- Upgrade Go to 1.22+ if you haven't already. The new mux is a game changer.
- Rewrite your routes using patterns with method and parameters. Eliminate all manual
r.Methodswitches. - Create a middleware.go file with logging, recovery, and auth middleware. Experiment with composition.
- Test with httptest — the standard library has
httptest.NewServerto test your handlers without starting a real server. - For a deeper dive into Go for backend, check our main guide on Go for Backend: Concurrency, REST APIs and Microservices.
A well-built HTTP server with net/http gives you total control. No dependencies, no black magic. Just code that does exactly what you wrote. Just the way we like it.