The Boring Stack


3 min read

No Router Framework

Go's standard library routes HTTP well enough

Part two of the boring stack. Part one was about the language. This one is about the first dependency most Go web apps add, and why I don’t.

For years, the first thing anyone did in a Go web project was pick a router: gorilla/mux, chi, Gin, Echo. The standard http.ServeMux couldn’t match on HTTP methods or extract parameters from the path, so you needed something on top.

That changed with Go 1.22. Since then, the standard library does both:

mux := http.NewServeMux()
mux.HandleFunc("GET /items", h.ListItems)
mux.HandleFunc("GET /items/{id}", h.ShowItem)
mux.HandleFunc("POST /items/{id}/archive", h.ArchiveItem)

Inside the handler, r.PathValue("id") returns the parameter. Requests with the wrong method get a 405 automatically. That covers everything I need from a router.

All routes in one file

In my apps, every route lives in one file, internal/routes/routes.go, grouped into public routes, routes behind a login and webhooks. When I want to know what an app can do, I read one file from top to bottom.

Authentication is applied by wrapping each route, not by mounting groups:

mux.Handle("GET /settings", middleware.RequireAuth(h.Settings))
mux.Handle("GET /admin/users", middleware.RequireAdmin(h.Users))
mux.Handle("GET /login", middleware.RequireGuest(h.LoginForm))

It’s more typing than a route group. It’s also impossible to misread: the protection is on the same line as the route.

Middleware is just a function

Frameworks make middleware look special. It isn’t. In Go, middleware is a function that takes an http.Handler and returns one. Chaining them is a loop.

My apps run every request through the same chain, in a fixed order: configuration, a nonce for the content security policy, security headers, request logging, CSRF protection, authentication, locale, and the current URL path. The order matters. CSRF has to run before anything changes state, and the locale needs to know who the user is. Written as plain functions, the order is visible in one place and nothing happens that I didn’t write.

Request state through the context

Handlers and templates need to know things about the request: the logged-in user, the CSRF token, the language. Frameworks offer their own context objects for that. The standard library already has one: context.Context.

I keep all request-scoped values in one small package with typed accessors, like ctxkeys.User(ctx). It’s the only way that state reaches handlers and templates. When something reads the user, I know exactly where it came from.

What it costs

  • A bit more code. No route groups, no automatic binding of form values to structs, no built-in validation. I write a few more lines, and every one of them is mine to read.
  • No ecosystem of plugins. I don’t miss it. The plugins I would use (logging, recovery, CORS, rate limiting) are each a few dozen lines.
  • You need Go 1.22 or newer. That’s been out since February 2024.

When I’d choose differently

If you have an existing app on chi or Echo that works, there’s no reason to migrate it. But for a new project, I’d add a router framework only when a concrete need shows up. So far, it hasn’t.

Next: how the HTML gets to the browser.

More in The Boring Stack

Share