04 - HTTP Routing with net/http

📋 Jump to Takeaways

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.

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 writeJSON and writeError helpers and use them everywhere
  • json.NewDecoder(r.Body).Decode(&input) for reading request bodies
  • Group routes into register* functions to keep main clean
  • You don't need Gin, Chi, or Echo for a REST API anymore

🚀 Ready to run?

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy