04 - HTTP Routing with net/http
📋 Jump to TakeawaysGo 1.22 changed everything. Before, net/http could only match exact paths. No method routing, no path parameters. You needed Chi, Gorilla, or Gin for anything real. Not anymore.
The New Routing (Go 1.22+)
mux := http.NewServeMux()
// Method + path
mux.HandleFunc("GET /bookmarks", listBookmarks)
mux.HandleFunc("POST /bookmarks", createBookmark)
// Path parameters
mux.HandleFunc("GET /bookmarks/{id}", getBookmark)
mux.HandleFunc("PUT /bookmarks/{id}", updateBookmark)
mux.HandleFunc("DELETE /bookmarks/{id}", deleteBookmark)That's a full REST API router. No external packages.
Path Parameters
Extract parameters with r.PathValue:
func getBookmark(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
slog.Info("fetching bookmark", "id", id)
// ... look up bookmark by id ...
}Wildcard Matching
Match the rest of the path with {path...}:
// Matches /files/images/photo.jpg, /files/docs/readme.txt, etc.
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
filePath := r.PathValue("path") // "images/photo.jpg"
fmt.Fprintf(w, "serving: %s", filePath)
})JSON Responses
Most APIs return JSON. Write a small helper instead of repeating json.Marshal everywhere:
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}Order matters: set headers first, then WriteHeader, then write the body. The first call to Write implicitly sends a 200 OK if you haven't called WriteHeader yet. If you call WriteHeader after writing the body, the status code is ignored and Go logs a warning. This is a common bug — always set the status before writing.
Use it in handlers:
type Bookmark struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
}
func listBookmarks(w http.ResponseWriter, r *http.Request) {
bookmarks := []Bookmark{
{ID: "1", URL: "https://go.dev", Title: "Go"},
{ID: "2", URL: "https://pkg.go.dev", Title: "Go Packages"},
}
writeJSON(w, http.StatusOK, bookmarks)
}Reading JSON Request Bodies
func createBookmark(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if input.URL == "" {
writeError(w, http.StatusBadRequest, "url is required")
return
}
bookmark := Bookmark{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
URL: input.URL,
Title: input.Title,
}
// TODO: persist in database (we'll do this in the database lesson)
slog.Info("bookmark created", "id", bookmark.ID, "url", bookmark.URL)
writeJSON(w, http.StatusCreated, bookmark)
}The var input struct { ... } is an anonymous struct — defined inline, used once. You don't need a named CreateBookmarkInput type for every request body. Keep named types for things that get passed around (like Bookmark), use anonymous structs for one-off input parsing.
Route Organization
As your API grows, group related routes into functions:
func registerBookmarkRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /bookmarks", listBookmarks)
mux.HandleFunc("POST /bookmarks", createBookmark)
mux.HandleFunc("GET /bookmarks/{id}", getBookmark)
mux.HandleFunc("PUT /bookmarks/{id}", updateBookmark)
mux.HandleFunc("DELETE /bookmarks/{id}", deleteBookmark)
}
func registerHealthRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
}
func main() {
cfg := LoadConfig()
setupLogger(cfg.LogLevel, "text")
mux := http.NewServeMux()
registerHealthRoutes(mux)
registerBookmarkRoutes(mux)
slog.Info("listening", "addr", ":"+cfg.Port)
log.Fatal(http.ListenAndServe(":"+cfg.Port, mux))
}Each register* function owns its routes. main stays thin.
Why Not Gin/Chi/Echo?
| Feature | net/http (Go 1.22+) | Gin/Chi/Echo |
|---|---|---|
| Method routing | "GET /path" |
✅ |
| Path parameters | {id} |
✅ |
| Wildcards | {path...} |
✅ |
| Middleware | func(http.Handler) http.Handler |
✅ (different API) |
| Route groups | Functions | Built-in |
| Validation | Write your own | Built-in |
| Binding | json.Decoder |
Built-in |
Frameworks add convenience for validation, binding, and route groups. But they also add dependencies, their own abstractions, and upgrade burden. For most APIs, net/http is enough. You can always add a framework later if you need it, but start without one.
Key Takeaways
- Go 1.22+ supports
"METHOD /path/{param}"routing natively r.PathValue("param")extracts path parameters- Write
writeJSONandwriteErrorhelpers and use them everywhere json.NewDecoder(r.Body).Decode(&input)for reading request bodies- Group routes into
register*functions to keepmainclean - You don't need Gin, Chi, or Echo for a REST API anymore