Building an HTTP Server in Go: Routing, Middleware and JSON APIs
A JSON product API with Go 1.22 routing, path parameters, middleware, thread-safe state, timeouts, tests and graceful shutdown.
On this page
Go's standard library can serve production traffic without any framework. Since Go 1.22, its router (http.ServeMux) supports HTTP methods and path parameters, which removed the main reason people reached for third-party routers. In this article we build a small JSON product API with routing, middleware, validation and safe concurrent state.
Hello, server
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}A handler receives an http.ResponseWriter (where you write the response) and an *http.Request (everything the client sent). Every request runs in its own goroutine, so one slow request never blocks the others. Keep that in mind: handlers run concurrently.
Routing with methods and path parameters (Go 1.22+)
Use your own ServeMux instead of the global default, and put the method in the pattern:
mux := http.NewServeMux()
mux.HandleFunc("GET /products", listProducts)
mux.HandleFunc("GET /products/{id}", getProduct)
mux.HandleFunc("POST /products", createProduct)GET /products only matches GET (and HEAD). Other methods automatically get 405 Method Not Allowed.{id} is a wildcard; read it with r.PathValue("id").Before Go 1.22 you had to check r.Method manually in every handler (the original version of this project did exactly that).
A JSON response helper
Headers must be set before WriteHeader; after the status line is sent, header changes are ignored. A small helper keeps that order right:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Println("encode response:", err)
}
}State that is safe under concurrency
The original server kept products in a global slice and appended to it from the create handler. Because handlers run concurrently, two simultaneous POSTs could corrupt the slice: a data race (see the mutex article). Put the state in a struct with a lock:
type Product struct {
ID int `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
}
type Store struct {
mu sync.RWMutex
products []Product
}
func (s *Store) list(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
writeJSON(w, http.StatusOK, s.products)
}
func (s *Store) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, p := range s.products {
if p.ID == id {
writeJSON(w, http.StatusOK, p)
return
}
}
writeJSON(w, http.StatusNotFound, map[string]string{"error": "product not found"})
}
func (s *Store) create(w http.ResponseWriter, r *http.Request) {
var p Product
body := http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MB
if err := json.NewDecoder(body).Decode(&p); err != nil || p.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid product"})
return
}
s.mu.Lock()
p.ID = len(s.products) + 1
s.products = append(s.products, p)
s.mu.Unlock()
writeJSON(w, http.StatusCreated, p)
}Methods on *Store are valid handler functions, so the store is passed in explicitly instead of living in a global variable. That makes the code testable.
Decode. The original code called decoder.Decode(&newProduct) without checking it, so malformed JSON created an empty product. Validate input and limit the body size with http.MaxBytesReader.Middleware
Middleware is a function that takes a handler and returns a new handler that wraps it. It is the closure pattern from earlier in the series. Here is CORS handling, including the browser's preflight OPTIONS request:
func cors(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, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions { // preflight: answer and stop
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}Wrap the whole router to apply middleware to every route: handler := logging(cors(mux)).
Access-Control-Allow-Origin: * is fine for a public, read-only API. If your API uses cookies or auth headers, allow only your real frontend origin.Putting it together, with timeouts
func main() {
store := &Store{products: []Product{{ID: 1, Title: "Keyboard", Price: 49.99}}}
mux := http.NewServeMux()
mux.HandleFunc("GET /products", store.list)
mux.HandleFunc("GET /products/{id}", store.get)
mux.HandleFunc("POST /products", store.create)
srv := &http.Server{
Addr: ":8080",
Handler: logging(cors(mux)),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on :8080")
log.Fatal(srv.ListenAndServe())
}http.ListenAndServe uses a server with no timeouts, which makes it easy for slow or malicious clients to hold connections open. Configure an http.Server explicitly.
I tested this API in-process with httptest on Go 1.27:
GET /products -> 200 [{"id":1,"title":"Keyboard","price":49.99}]
POST /products -> 201 {"id":2,"title":"Mouse","price":19.5}
GET /products/2 -> 200 {"id":2,"title":"Mouse","price":19.5}
GET /products/abc -> 400 {"error":"invalid id"}
DELETE /products -> 405 Method Not Allowed
OPTIONS /products -> 204Testing handlers with httptest
func TestCreateProduct(t *testing.T) {
store := &Store{}
req := httptest.NewRequest(http.MethodPost, "/products",
strings.NewReader(`{"title":"Mouse","price":19.5}`))
rec := httptest.NewRecorder()
store.create(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body)
}
}No network, no ports: httptest.NewRecorder captures what the handler writes.
Graceful shutdown
In production, stop accepting new requests and let in-flight ones finish when the process receives SIGTERM (for example, during a deploy):
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // wait for the signal
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx) // finish in-flight requests, then exitKey takeaways
ServeMux supports "GET /path/{id}" patterns and r.PathValue.WriteHeader; use a writeJSON helper.Decode errors.func(http.Handler) http.Handler.http.Server with timeouts and graceful shutdown.Last updated on