Updated Jul 17, 2026

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 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

🎁 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?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy