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

Go
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))
}
Bash
curl http://localhost:8080/users
JSON
[{"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

Go
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)
}
Bash
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:

Go
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:

Go
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":

Go
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 allowing GET to trigger a handler meant only for POST.
  • 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).