Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 44

Routing with ServeMux

Recommended resources

Hello! Welcome back.

In our last lesson, you built your first HTTP server in Go. You learned how to use the net/http package to create handlers, differentiate between HTTP methods, and configure a production-ready server. That was a huge step toward building your own backend services and DevOps tools.

Today, we'll build directly on that foundation to tackle a more advanced and practical topic: request routing. This lesson will guide you through implementing more sophisticated routing logic, which is essential for any real-world API or web service. Specifically, we will focus on the learning outcome: Implement request routing using http.ServeMux or a third-party router.

You now know how to handle a static path like /hello, but what about dynamic paths like /users/123 or /repositories/my-project? How do you organize groups of related endpoints, like for API versioning (/api/v1/...)?

We'll explore two approaches to answer these questions:

  1. Advanced patterns using the standard library's http.ServeMux (as of Go 1.22).
  2. Using a popular, lightweight third-party router to see the benefits it can provide.

By the end of this lesson, you'll be able to make an informed decision about which routing strategy is best for your own projects.

1. Handling Path Parameters with http.ServeMux

One of the most common requirements in routing is handling dynamic segments in a URL path. These are often called "path parameters" or "URL variables". For example, in a tool that interacts with a CI/CD system, you might need an endpoint to get the status of a specific build, using a URL like /builds/{buildID}.

With Go 1.22 and later, the standard http.ServeMux supports this out of the box.

The standard library now has all you need for advanced routing in Go.

To start, let's see how this new feature works. The video 'The standard library now has all you need for advanced routing in Go' provides a concise introduction to defining and using path parameters.

Watch the segment from 00:24 to 02:08. Focus on: The {param} syntax used to define a path parameter in the route pattern. How to access the parameter's value inside the handler using r.PathValue("param"). The note about Go version 1.22 being required in your go.mod file.

Now, let's look at a more complete, hands-on example.

DIY Golang Web Server: No Dependencies Needed!

The video 'DIY Golang Web Server: No Dependencies Needed!' demonstrates this concept by building a simple user API. It's a great practical walkthrough.

Watch from 13:51 to 17:45. This section shows a complete implementation for a GET /users/{id} endpoint. Pay attention to how the id is extracted from the path, converted from a string to an integer, and then used to retrieve data.

Let's consolidate these ideas into a clear example. Imagine we're building a simple configuration server that stores key-value pairs. We need an endpoint to retrieve a value for a specific key.

Create a new main.go file with the following code:

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"
)

// A simple in-memory key-value store.
var config = map[string]string{
	"db.host":    "localhost",
	"db.port":    "5432",
	"api.version": "v2",
}

// configHandler retrieves a configuration value by key.
func configHandler(w http.ResponseWriter, r *http.Request) {
	// Extract the 'key' path parameter from the request URL.
	key := r.PathValue("key")
	
	// Since PathValue also matches parts of a path, we might get
	// a key with slashes if the route was defined as `/config/{key...}`.
	// Let's ensure our simple keys are handled correctly.
	key = strings.TrimPrefix(key, "/")

	value, ok := config[key]
	if !ok {
		// If the key doesn't exist, send a 404 Not Found response.
		http.NotFound(w, r)
		return
	}

	// Write the value to the response.
	fmt.Fprintf(w, "Value for key '%s': %s\n", key, value)
}

func main() {
	mux := http.NewServeMux()

	// Register the handler for a path with a dynamic 'key' parameter.
	// The {key} part is the placeholder for the path parameter.
	mux.HandleFunc("GET /config/{key}", configHandler)

	fmt.Println("Starting server on port 8080...")
	err := http.ListenAndServe(":8080", mux)
	if err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

Run this server (go run .) and test it from another terminal with curl:

# Request a key that exists
curl http://localhost:8080/config/db.host
# Output: Value for key 'db.host': localhost

# Request another key
curl http://localhost:8080/config/api.version
# Output: Value for key 'api.version': v2

# Request a key that does not exist
curl http://localhost:8080/config/nonexistent.key
# Output: 404 page not found

This pattern is powerful and covers a large number of use cases for building automation tools and APIs without any external dependencies.


2. Sub-routing and Grouping with http.ServeMux

Another common pattern is grouping related routes under a common prefix. For example, all your API endpoints might live under /api/v1/. This helps with organization and is especially useful when you want to apply common logic (like authentication or logging middleware) to a whole group of routes.

You can achieve this with the standard library by creating separate ServeMux instances and "mounting" them on a main router.

The standard library now has all you need for advanced routing in Go.

The 'Dreams of Code' video also covers this topic, which he calls 'sub routing'. He demonstrates how to version an API using this pattern.

Watch from 09:33 to 11:07. Notice how he creates a separate router for the API and then uses http.StripPrefix to wire it up to the main router. This is the key technique for sub-routing with the standard library.

Let's build an example. We'll create a main router and two sub-routers: one for general site pages and one for API endpoints.

package main

import (
	"fmt"
	"log"
	"net/http"
)

// --- API Handlers ---
func getUsers(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "API: Getting all users")
}

func getStatus(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "API: System status is OK")
}

// --- Site Handlers ---
func homePage(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Site: Welcome to the homepage!")
}

func aboutPage(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Site: This is the about page.")
}

func main() {
	// Main router
	mainMux := http.NewServeMux()

	// API sub-router
	apiMux := http.NewServeMux()
	apiMux.HandleFunc("GET /users", getUsers)
	apiMux.HandleFunc("GET /status", getStatus)

	// Site sub-router
	siteMux := http.NewServeMux()
	siteMux.HandleFunc("GET /", homePage)
	siteMux.HandleFunc("GET /about", aboutPage)
	
	// Mount the sub-routers on the main router.
	// The " /" at the end of the pattern is a catch-all for that path prefix.
	// http.StripPrefix removes the prefix before passing it to the sub-router's handler.
	mainMux.Handle("/api/", http.StripPrefix("/api", apiMux))
	mainMux.Handle("/", siteMux) // Site should be last as it's a catch-all

	fmt.Println("Starting server with sub-routers on port 8080...")
	err := http.ListenAndServe(":8080", mainMux)
	if err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

Run this code and test the different routes:

# Test a site route
curl http://localhost:8080/about
# Output: Site: This is the about page.

# Test an API route
curl http://localhost:8080/api/users
# Output: API: Getting all users

# Test another API route
curl http://localhost:8080/api/status
# Output: API: System status is OK

This works well, but as you can see, the setup with http.StripPrefix can feel a bit manual. This brings us to our next topic.


3. When to Use a Third-Party Router: Introducing chi

While the standard library is capable, the community has built excellent third-party routers that provide a more expressive and convenient API for complex routing needs. One of the most popular is chi. It's known for being lightweight, fast, and idiomatic, meaning it's designed to work well with standard net/http components.

You don't always need a third-party router, but they become very helpful when your application grows. Let's explore why.

Go's 1.22+ ServeMux vs Chi Router

The article 'Go's 1.22+ ServeMux vs Chi Router' by Jon Calhoun provides an expert comparison. It perfectly articulates the trade-offs and is invaluable for making an informed decision.

Please read the following sections from the article: The section comparing how HTTP verbs are declared (starts with "The most obvious difference..."). The section on URL path variables (starts with "One of the less obvious differences..."). The section discussing middleware and sub-routers (starts with "Another notable difference..."). Focus on the differences in syntax and capabilities that chi offers over the standard ServeMux.

Based on that reading, here's a summary of why you might choose chi:

  • More Expressive Syntax: chi provides helper methods for each HTTP verb (r.Get, r.Post, etc.), which many developers find cleaner than the string-based mux.HandleFunc("GET /path", ...).
  • Advanced Path Parameters: chi allows for more complex path parameter definitions, including regular expressions (/files/{name:[a-z-]+\\.csv}) to validate the format at the routing layer.
  • Cleaner Sub-routing and Middleware: Grouping routes and applying middleware to them is much more straightforward with chi's r.Route(...) and r.Use(...) helpers.

Let's refactor our sub-routing example from before to use chi. First, you'll need to install it:

go get -u github.com/go-chi/chi/v5

Now, here is the equivalent server implementation using chi:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	// Optional: chi's middleware package is very useful
	"github.com/go-chi/chi/v5/middleware"
)

// --- API Handlers (same as before) ---
func getUsers(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "API: Getting all users")
}

func getStatus(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "API: System status is OK")
}

// --- Site Handlers (same as before) ---
func homePage(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Site: Welcome to the homepage!")
}

func aboutPage(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Site: This is the about page.")
}

func main() {
	// Create a new chi router.
	r := chi.NewRouter()

	// chi comes with some useful default middleware.
	r.Use(middleware.Logger) // Logs requests to the console.
	r.Use(middleware.Recoverer) // Recovers from panics.

	// Group routes for the site.
	r.Get("/", homePage)
	r.Get("/about", aboutPage)
	
	// Group routes under the "/api" prefix using r.Route.
	r.Route("/api", func(r chi.Router) {
		r.Get("/users", getUsers)
		r.Get("/status", getStatus)
	})

	fmt.Println("Starting server with chi router on port 8080...")
	err := http.ListenAndServe(":8080", r)
	if err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

Notice how r.Route provides a much cleaner way to define the /api group compared to the http.StripPrefix approach. The code is more readable and less verbose.

Test your understanding!

Using chi, you want to define an endpoint to delete a user by their numeric ID. The URL should be DELETE /users/{userID}. How would you define this route and access the userID inside the handler?

Hint: chi has a function chi.URLParam(r *http.Request, key string) string.

Show answer

First, you would define the route on your chi router r:

r.Delete("/users/{userID}", deleteUserHandler)

Then, inside your deleteUserHandler, you would access the userID like this:

func deleteUserHandler(w http.ResponseWriter, r *http.Request) {
    userIDStr := chi.URLParam(r, "userID")
    
    // You would then typically convert the string to an integer
    // userID, err := strconv.Atoi(userIDStr)
    // ... handle error and deletion logic ...

    fmt.Fprintf(w, "Attempting to delete user with ID: %s", userIDStr)
}

Conclusion

You've now explored the spectrum of request routing in Go, from the powerful built-in http.ServeMux to the convenient and expressive chi router.

Key Takeaways:

  • Path Parameters: The standard library http.ServeMux (Go 1.22+) fully supports path parameters (/items/{id}) and accessing their values with r.PathValue("id").
  • Sub-routing: Grouping routes is possible with the standard library using multiple ServeMux instances and http.StripPrefix, but it can be verbose.
  • Third-Party Routers: Libraries like chi simplify advanced routing with a cleaner API for grouping routes (r.Route), applying middleware (r.Use), and defining more complex path parameters.
  • The Choice: For simple tools or services, the standard http.ServeMux is often sufficient and avoids an external dependency. For larger, more complex APIs where readability and features like group middleware are important, a router like chi is an excellent choice.

Next Up

You've learned how to route requests to the correct handlers. The next logical step is to properly implement those handlers. In the next lesson, we will dive into building HTTP handlers for GET and POST requests involving JSON payloads. You'll learn how to decode JSON from a request body into a Go struct and encode a Go struct into a JSON response—a fundamental skill for building any modern API.