Building a REST API with net/http
A practical JSON REST API using only the standard library, plus a look at where Gin fits in.
Why the standard library is enough to start
Go's net/http package is production-capable on its own — many real services never need a framework at all. Understanding it first makes frameworks like Gin (covered briefly at the end) much easier to reason about.
A minimal JSON API
package main
import (
"encoding/json"
"log"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
users := []User{
{ID: 1, Name: "Ali"},
{ID: 2, Name: "Zara"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func main() {
http.HandleFunc("/users", usersHandler)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
curl http://localhost:8080/users
[{"id":1,"name":"Ali"},{"id":2,"name":"Zara"}]
Struct tags like `json:"id"` control exactly how encoding/json names each field in the output — without them, Go's exported (capitalized) field names would be used as-is.
Handling different methods and reading the request body
func createUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var input User
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(input)
}
curl -X POST http://localhost:8080/users -d '{"id":3,"name":"Bilal"}'
Routing with net/http's ServeMux (Go 1.22+)
Modern Go added method- and path-parameter-aware routing directly to the standard library, removing the need for a router library in many simple services:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "requested user id: %s", id)
})
mux.HandleFunc("POST /users", createUserHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
Middleware — a plain function wrapping a handler
Because handlers are just functions matching http.HandlerFunc, "middleware" in Go is simply a function that wraps one handler with another:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/users", usersHandler)
log.Fatal(http.ListenAndServe(":8080", loggingMiddleware(mux)))
}
Where a framework like Gin helps
net/http gets verbose once you need path-parameter validation, structured JSON error responses, request binding/validation, and grouped route middleware across a large API surface. Gin wraps these concerns in a small, fast API while still being "just Go underneath":
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
c.JSON(200, gin.H{"id": c.Param("id")})
})
r.Run(":8080")
}
For gRPC-based internal service-to-service APIs instead of JSON/REST, Go's ecosystem also has first-class grpc-go support — a common combination in larger Go microservice architectures.
Common mistakes
- Forgetting to set
Content-Type: application/json— clients may fail to parse the response correctly without it. - Not checking
r.Method(or not using method-aware routing) — accidentally allowingGETto trigger a handler meant only forPOST. - Ignoring the error returned by
json.NewDecoder(...).Decode(...), silently accepting malformed request bodies.
Interview questions
Q: Why might a team choose plain net/http over a framework like Gin?
Fewer dependencies, a smaller learning curve for anyone who already knows Go, and the standard library's method/path-parameter routing (Go 1.22+) covers a large share of what frameworks used to be necessary for.
Q: How does encoding/json decide what to name JSON fields?
By default it uses the Go struct field's name as-is (must be exported/capitalized to be visible to the package at all). Struct tags like `json:"name"` override this to control the exact JSON key name, casing, and omission behavior (omitempty).