Back

/ 9 min read

How to handle basic routing using the Go standard library

Introduction

In the last post, we learnt how to create a simple web server in go and how to handle a /ping request that pongs.

Today, we’re going to see how we can handle some basic routing, as well as describe more complex, nested routes using the net/http package and ServeMux.

Defining Routes

We learnt last time that you can define routes using the Handle and HandleFunc methods on ServeMux.

package main
import (
"fmt"
"net/http"
)
func main() {
router := http.NewServeMux()
server:= http.Server{
Addr:":8080",
Handler: router
}
router.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "pong")
})
server.ListenAndServe()
}

That’s it. That’s all you need to know how to define basic routes in Go. Simply define the absolute route, and give it a Handler function.

You could also define routes routes with multiple segments this way, but that’s inefficient. Which is why it’s best to define complex routes through nesting.

Defining Nested Routes

Nested routes in Go are tricky.

Nested Routes in Node.js

If you were using something like express in Node.js and wanted to define a /users/first router, you’d do something like:

import express from 'express';
const app = express()
const router = express.Router()
app.use('/users', router);
router.get('/first', (req, res) => {
res.status(200).send("First");
})
app.listen(8000, () => {
console.log('Listening on port 8000')
})

And it would work!

Making a request to http://localhost:8000/users/first would return a status 200 and First.

Let’s try doing thing same thing in Go.

But we’re going to define two routes instead of just /users/first

First route is going to be /users that will act as the control, and simply return Base Users.

And the second is going to be /users/first that will return First.

func main() {
router := http.NewServeMux()
http.Handle("/users", router)
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Base Users")
})
router.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "First")
})
http.ListenAndServe(":8080", nil)
}

Great! And now let’s try making that request.

Terminal window
iwr http://localhost:8080/users/first
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 14 Jan 2025 10:30:11 GMT
Content-Length: 19
404 page not found

Hmm… 404. I wonder why.

Trailing Slash

Looking at the docs here, you’ll see that due to the way Go’s route pattern matching works, you need a trailing slash at the end of your route to act as a catch-all wildcard. Without this, Go thinks your route ends at /users and that /users/first doesn’t exist. So, it throws a 404!

Let’s try adding that trailing slash to /users and see if this thing works.

func main() {
userRouter := http.NewServeMux();
userRouter.HandleFunc("/first", func (w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "First")
})
http.Handle("/users/", userRouter)
http.ListenAndServe(":8080", nil)
}

Let’s go ahead and make that request again:

Terminal window
iwr http://localhost:8080/users/first
HTTP/1.1 200 OK
Date: Tue, 14 Jan 2025 11:19:10 GMT
Content-Length: 10
Content-Type: text/plain; charset=utf-8
Base Users

Okay, it… worked? We definitely got something. But it seems like the request got routed to /users instead of /users/first

Debugging The Router

Let’s debug this. We know the Handle function takes a path string and a Handler. The path string is fine, but let’s see if we can get more information if we write our own handler.

Digging into the source code, we find that the net/http package matches the path (called pattern in the source) we define in the Handle and HandleFunc methods against the URL field in the http.Request object. So if the URL and Pattern match, it should trigger the Handler.

Let’s update our main.go file with our debug handler:

type DebugHandler struct {
handler http.Handler
}
func (d DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[DEBUGGER]\n")
fmt.Printf("Pattern\t=> %q\n", r.Pattern)
fmt.Printf("URL\t=> %q\n", r.URL)
d.handler.ServeHTTP(w, r)
}
func debugFunc(w http.ResponseWriter, r *http.Request) {
fmt.Println()
fmt.Printf("[HANDLER]\n")
fmt.Printf("Pattern\t=> %q\n", r.Pattern)
fmt.Printf("URL\t=> %q\n", r.URL)
}
func main() {
router := http.NewServeMux()
http.Handle("/users/", DebugHandler{router})
router.HandleFunc("/", debugFunc)
router.HandleFunc("/first", debugFunc)
http.ListenAndServe(":8080", nil)
}

Let’s go over this debugging setup.

First, we’ve created a Handler for debugging called DebugHandler that takes in a http.ServeMux. This handler implements the http.Handler interface, which means it implements the ServeHTTP function.

Second, we know that the URL of the incoming http.Request is matched against the pattern so we log them both to the screen.

Then we’ve also defined a debugging function that will handle the actual route: debugFunc and also log the information to the screen.

Now, if we make a request to /users/first, we get this in the console:

[DEBUGGER]
Pattern => "/users/"
URL => "/users/first"
[HANDLER]
Pattern => "/"
URL => "/users/first"

So, first the URL matches against the /users/ pattern and due to the trailing slash acting as wildcard. Next, it gets passed to the router in which it should ideally match against the /first handler, but instead, it matches against the / despite /first being more precise, and taking precedence (Read here).

It seems like whatever pattern we define, we need to make sure it accounts for the entire url, and not just nested routes.

So, potentially, we could define our route to be /users/first in our router and have it work.

The Quick Fix

We update the route in our file to:

// ...
router.HandleFunc("/users/first", debugFunc)
// ...

And making request to the modified router:

[DEBUGGER]
Pattern => "/users/"
URL => "/users/first"
[HANDLER]
Pattern => "/users/first"
URL => "/users/first"

It works!

But it also defeats the purpose of having nested routers. We would have to keep track of all the places we have defined nested routes, and update them all incase things ever change.

And this is just a mistake waiting to happen.

Let’s see if we can fix this.

The Verbose Fix

It seems like the problem occurs because the URL in our http.Request keeps the whole string intact throughout it’s nested Handler journey.

Going over the docs, we find a function called StripPrefix that removes a specified string from the start of the URL and invokes the Handler.

Incorporating that into our file, we get:

// ...
removeUsersPrefix := http.StripPrefix("/users/", DebugHandler{userRouter})
http.Handle("/users/", DebugHandler{removeUsersPrefix})
// ...

We’ve also wrapped the removeUsersPrefix handler in a DebugHandler so we can see what’s going on inside.

And now if we make a request to /users/first:

HTTP/1.1 200 OK
Date: Tue, 14 Jan 2025 11:19:10 GMT
Content-Length: 10
Content-Type: text/plain; charset=utf-8
Base Users

Okay so it’s still behaving like it was before. Our routes still aren’t working.

Let’s look at our console to see if our debugger is any help.

[DEBUGGER] (1)
Pattern => "/users/"
URL => "/users/first"
[DEBUGGER] (2)
Pattern => "/users/"
URL => "first"
  1. This is the statement captured before the request is Piped to the prefix remover
  2. This is after it’s been piped.

It matches against /users/ in (1) and then our http.StripPrefix handler removes the /users/ from the URL (/users/first), leaving only first.

And that isn’t able to match against /first

Making a small change to our StripPrefix, we should be able to solve this issue. We’ll remove the trailing slash from /users/ so that a valid URL remains

// ...
removeUsersPrefix := http.StripPrefix("/users", DebugHandler{router})
// ...

Making the request again, and looking at our console, we can see that it worked! Our /first route is being triggered.

[DEBUGGER]
Pattern => "/users/"
URL => "/users/first"
[DEBUGGER]
Pattern => "/users/"
URL => "/first"
[HANDLER]
Pattern => "/first"
URL => "/first"

Clean Up

But this is too many things to get right just get a basic subrouter going. Let’s see if we can make this easier for us in the future.

removeUsersPrefix := http.StripPrefix("/users", DebugHandler{router})
http.Handle("/users/", DebugHandler{removeUsersPrefix})

Looking at the code, there’s a couple of things we need to get right to have the subrouter work:

  1. We need to define our route in the Handle function with a trailing slash
  2. We need to strip the URL off that route before we pass that to sub handlers
  3. We need to strip the route without the trailing slash
  4. And then finally, we need to attach our actual sub router to our StripPrefix handler

Perhaps we can make a wrapper around http.ServeMux (since that handles the routing) that let’s us do all these without having to think about it again and again.

We’ll create a router.go file in pkgs/router/

pkgs/router/router.go
package router
import "net/http"
type Router struct {
*http.ServerMux
}
func NewRouter() Router {
return Router{http.NewServeMux()}
}
func (router *Router) HandleSubroute(route string, handler http.Handler) {
router.Handle(route + "/", http.StripPrefix(route, handler))
}

We embedded the ServeMux type in a custom struct, giving us access to all methods that ServeMux has including Handle and HandleFunc without having to write our own wrappers for them.

We create two functions: NewRouter that returns a new Router; and HandleSubroute method on that Router that handles all the trailing slash, and prefix stripping for us.

Note how we used *http.Handler for the subroute handler instead of ServeMux this is to make it as compatible and composable possible with net/http.

Let’s rewrite our main.go using this newly created package.

package main
import (
"bamboo/basic-routing/pkgs/router"
"fmt"
"net/http"
)
func main() {
base := router.NewRouter()
users := router.NewRouter()
base.HandleSubroute("/users", users)
// Handles /users
users.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Base Users")
})
// Handles /users/first
users.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "First")
})
http.ListenAndServe(":8080", base)
}

We did have to introduce a base router that we can pass into our server, but other than that, this looks a lot cleaner! And it makes it easy to define nested routes.

If you try to make a request to /users/first now, you’ll get back First.

Plus, our structure makes it really easy to define further nested subroutes as well.

We’ll create a route /users/posts/get that returns Got User Post

package main
import (
"bamboo/basic-routing/pkgs/router"
"fmt"
"net/http"
)
func main(){
base := router.NewRouter()
users := router.NewRouter()
base.HandleSubroute("/users", users)
// Handles /users/first
users.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "First")
})
// Handles /users
users.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Base Users")
})
// New router to handle /users/posts/*
posts := router.NewRouter()
// Handles /users/posts/*
users.HandleSubroute("/posts", posts)
// Handle /users/posts/get
posts.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Got User Post!")
})
http.ListenAndServe(":8080", base)
}

Et voila! We can now handle basic nested routes in Go!


I hope you learnt something new, and had as much fun reading this as I had writing it.

If you wanna know about upcoming posts on this topic, you can sign up for my newsletter!

Happy Hacking!