Last month, I had to rebuild our team’s API from scratch. The old one? A mess of spaghetti code that nobody wanted to touch. I decided to go with Go and Gorilla Mux, and honestly, it was one of the better decisions I’ve made this year. Let me show you what I learned.
Why I Picked Gorilla Mux Over Standard Library
Go’s net/http package is decent. It’ll get the job done. But after writing route handlers for the hundredth time and parsing URL parameters manually, I was done. Gorilla Mux just makes everything easier – pattern matching works the way you’d expect, extracting route variables doesn’t require regex gymnastics, and the code ends up way cleaner.
Plus, it’s been around forever. If you run into issues, someone on Stack Overflow has probably already solved it.
Getting Started
Create a new directory and initialize your Go module:
mkdir bookstore-api
cd bookstore-api
go mod init bookstore-api
go get -u github.com/gorilla/mux
Nothing fancy here. We’re building a bookstore API because it’s simple enough to understand but realistic enough to be useful.
The Book Model
We’ll keep our domain model straightforward:
package main
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Price float64 `json:"price"`
ISBN string `json:"isbn"`
}
In a real project, you’d probably have more fields (publication date, genre, stock quantity, etc.), but this works for our example.
Setting Up Routes
Here’s where Gorilla Mux shines. Check out how clean the routing looks:
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/mux"
)
var books []Book
func main() {
books = []Book{
{ID: "1", Title: "The Go Programming Language", Author: "Alan Donovan", Price: 44.99, ISBN: "978-0134190440"},
{ID: "2", Title: "Learning Go", Author: "Jon Bodner", Price: 39.99, ISBN: "978-1492077213"},
}
router := mux.NewRouter()
router.HandleFunc("/api/books", GetBooks).Methods("GET")
router.HandleFunc("/api/books/{id}", GetBook).Methods("GET")
router.HandleFunc("/api/books", CreateBook).Methods("POST")
router.HandleFunc("/api/books/{id}", UpdateBook).Methods("PUT")
router.HandleFunc("/api/books/{id}", DeleteBook).Methods("DELETE")
log.Println("Server starting on port 8080...")
log.Fatal(http.ListenAndServe(":8080", router))
}
See that {id} part in the route? That’s a route variable. Gorilla extracts it for you automatically. No manual string splitting needed. Beautiful.
Writing the Handler Functions
Alright, time for the actual logic. I’ll start with the GET endpoints since they’re simpler:
func GetBooks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(books)
}
func GetBook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
params := mux.Vars(r)
for _, book := range books {
if book.ID == params["id"] {
json.NewEncoder(w).Encode(book)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "Book not found"})
}
That mux.Vars(r) call? That’s how you grab those route variables. Super simple.
Now for creating books:
func CreateBook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var book Book
err := json.NewDecoder(r.Body).Decode(&book)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request payload"})
return
}
if book.Title == "" || book.Author == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Title and author are required"})
return
}
books = append(books, book)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(book)
}
I added basic validation here. In production, you’d want something more robust – maybe use a validation library like go-playground/validator. But for now, checking if title and author exist is good enough.
Here’s the update handler:
func UpdateBook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
params := mux.Vars(r)
for index, book := range books {
if book.ID == params["id"] {
var updatedBook Book
err := json.NewDecoder(r.Body).Decode(&updatedBook)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request payload"})
return
}
updatedBook.ID = params["id"]
books[index] = updatedBook
json.NewEncoder(w).Encode(updatedBook)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "Book not found"})
}
And delete:
func DeleteBook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
params := mux.Vars(r)
for index, book := range books {
if book.ID == params["id"] {
books = append(books[:index], books[index+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "Book not found"})
}
Quick note on that delete operation – we’re using slice tricks to remove an item. It works fine for small datasets, but if you’re dealing with thousands of records, you’ll want a proper database with indexes.
Adding Middleware (This Is Where It Gets Good)
Middleware is basically a wrapper around your handlers. Want to log every request? Write a middleware. Need CORS headers? Middleware. Authentication? You guessed it – middleware.
Here’s a simple logging middleware:
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL)
next.ServeHTTP(w, r)
})
}
Every request gets logged before it hits your actual handler. Saved my bacon more than once when debugging production issues.
And here’s CORS middleware (you’ll need this if you’re building a frontend):
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
In production, don’t use * for Allow-Origin. Set it to your actual frontend domain. Security folks will thank you.
Wire up the middleware in your main function:
func main() {
// ... initialization code ...
router := mux.NewRouter()
router.Use(LoggingMiddleware)
router.Use(CORSMiddleware)
// ... route definitions ...
}
Making Error Handling Less Painful
I used to write error responses inline everywhere. Then I’d need to change the error format and have to update 20 different places. Learn from my mistakes – use helper functions:
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
func RespondWithError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(code),
Message: message,
})
}
func RespondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
response, err := json.Marshal(payload)
if err != nil {
RespondWithError(w, http.StatusInternalServerError, "Error encoding response")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(response)
}
Now when you need to return an error, just call RespondWithError(w, http.StatusBadRequest, "Something went wrong"). Way cleaner.
Testing It Out
Fire up your server and test with curl:
# Get all books
curl http://localhost:8080/api/books
# Get one book
curl http://localhost:8080/api/books/1
# Add a new book
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"id":"3","title":"Concurrency in Go","author":"Katherine Cox-Buday","price":34.99,"isbn":"978-1491941195"}'
# Update
curl -X PUT http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"The Go Programming Language","author":"Alan Donovan","price":49.99,"isbn":"978-0134190440"}'
# Delete
curl -X DELETE http://localhost:8080/api/books/2
Or use Postman if you prefer a GUI. I switch between both depending on my mood.
What About Production?
This code works, but before you deploy it anywhere important, you’ll want to:
Use a real database. That in-memory slice? Gone after every restart. PostgreSQL is my go-to, but MongoDB works fine too if you prefer document storage.
Add authentication. JWT tokens are popular. I’ve also used session-based auth with Redis. Depends on your requirements.
Implement rate limiting. Otherwise someone will hammer your API with requests and your server bill will make you cry. The golang.org/x/time/rate package is built for this.
Handle graceful shutdown. When you restart your server, you don’t want to kill requests mid-flight. Catch SIGTERM and SIGINT signals, then give your handlers time to finish.
Move configuration to environment variables. Hardcoding the port number is fine for development but terrible for production. Use the os package to read from env vars.
Add proper logging. That basic log.Printf won’t cut it in production. Look into structured logging with zap or zerolog.
Wrapping This Up
That’s the core of building APIs with Go and Gorilla Mux. Start with this foundation, then add complexity as you need it. I’ve shipped APIs using exactly this pattern that handle serious traffic without breaking a sweat.
The nice thing about Go is that it’s fast enough out of the box. You won’t need to optimize prematurely. Build it, test it, deploy it. If you hit performance issues later (and you probably won’t for a while), then you can optimize.
Got questions? Drop them below. I usually check comments a few times a we


