04 - HTTP Routing with net/http
📋 Jump to Takeaways🎁 What if the router you already have, with zero imports, could do method matching and path parameters that used to require a third-party package?
Go 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.
Method and Path Routing
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. updateBookmark and deleteBookmark follow the same pattern as the handlers shown below, so we won't spell them out separately.
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, so 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 and 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.
Frameworks vs net/http
| 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
🎁 Every handler needs a request ID, timing, and a panic net, but copy-pasting that into all of them is misery. What if you could wrap them all once and never repeat yourself?