An HTTP server with no framework
Go 1.22 routes by method and path in the standard library. You do not need gin, echo, or chi.
- Build a JSON API with Go 1.22 method-and-path routing and
r.PathValue. - Write handlers that set headers, then the status, then the body, and return after an error.
- Explain why a bare
http.ListenAndServeis unsafe and configurehttp.Servertimeouts. - Embed a file or a static folder into the binary with
//go:embedand serve it withhttp.FileServerFS.
Most Go tutorials reach for a web framework on page one. Since Go 1.22 that advice is out of date. net/http routes on method and path parameters by itself, and that was the main reason most projects added one.
Starting without a framework is not stubbornness. You learn what an HTTP handler is, and frameworks you meet later are easy to read because most of them wrap this.
The whole server
package mainimport ( "encoding/json" "log" "net/http")func main() { mux := http.NewServeMux() // Go 1.22: method and {param} routing, built in. mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") writeJSON(w, 200, map[string]string{"id": id}) }) mux.HandleFunc("POST /users", createUser) log.Println("listening on :8080") // Simple version: no timeouts. The next sections swap this for an // http.Server with timeouts, then add graceful shutdown. // It blocks on purpose: it serves until the process is stopped. log.Fatal(http.ListenAndServe(":8080", mux))}func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v)}func createUser(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid json"}) return } if in.Name == "" || in.Email == "" { writeJSON(w, 422, map[string]string{"error": "name and email are required"}) return } writeJSON(w, 201, in)}A handler is one function shape
Everything in net/http is built on this signature. w is where you write the response; r is everything the client sent.
func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") // headers FIRST w.WriteHeader(status) // then the status json.NewEncoder(w).Encode(v) // then the body}func createUser(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid json"}) return // ALWAYS return after writing an error } if in.Name == "" || in.Email == "" { writeJSON(w, 422, map[string]string{"error": "name and email are required"}) return } writeJSON(w, 201, in)}WriteHeader, then write the body. Headers set after WriteHeader are ignored. WriteHeader takes effect only once, and writing a body without it sends a 200. If you write an error after a response has started, you get "superfluous response.WriteHeader call" in your logs, the client keeps the first status, and the error JSON is glued onto the first body. Always return right after writing an error.Test it
go run .curl -s localhost:8080/health | jqcurl -s localhost:8080/users/42 | jqcurl -s -X POST localhost:8080/users -d '{"name":"a","email":"a@b.c"}' | jqcurl -s -X POST localhost:8080/users -d 'not json' -iThe production settings, now rather than later
http.ListenAndServe(":8080", mux) is fine for a tutorial and wrong for anything you deploy. A zero-value http.Server has no timeouts at all, so a slow or idle client can hold a connection open forever.
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, // the Slowloris defence people forget ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20,}log.Fatal(srv.ListenAndServe())Shipping files inside the binary with //go:embed
Sometimes the server needs a file of its own: a welcome page, a SQL schema, a folder of CSS and images. //go:embed copies those files into the binary at build time, so you deploy one file and nothing can go missing on the server.
Put the comment directly above a package-level variable. For one file, use a string or []byte and add import _ "embed" (the blank import switches the feature on). For many files, use embed.FS, which behaves like a small read-only folder, and hand it to http.FileServerFS (Go 1.22+; on older Go, http.FileServer(http.FS(static))).
The path is relative to the folder of the package containing the .go file, cannot use .., and the files must exist when you build or go build fails. Files starting with . or _ are skipped unless you write all:static.
package webimport ( "embed" "net/http")// one file, as a string: welcome.txt sits next to this file, in internal/web/////go:embed welcome.txtvar welcome string// a whole folder: static/index.html, static/app.css, ...////go:embed staticvar static embed.FSfunc routes(mux *http.ServeMux) { mux.HandleFunc("GET /welcome", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(welcome)) }) // GET /static/app.css serves the embedded static/app.css mux.Handle("GET /static/", http.FileServerFS(static))}- Go 1.22
net/httproutes on"GET /users/{id}"and reads params withr.PathValue. No framework needed. - A handler is
func(http.ResponseWriter, *http.Request). Everything else is a wrapper over that. - Headers, then
WriteHeader, then body. Alwaysreturnafter writing an error. - A bare
http.ListenAndServehas no timeouts. Configurehttp.Serveryourself.
GetUser(w, r) for the route GET /users/{id}. When id is a positive whole number, reply 200 with Content-Type: application/json and the body {"id":7,"name":"user-7"} (for id 7). Otherwise reply 400 with Content-Type: application/json and {"error":"bad id"}. Read the id with r.PathValue("id") and convert it with strconv.Atoi. The hidden tests call it through a ServeMux with httptest.NewRecorder and httptest.NewRequest, no real server needed.Tab indents. Press Esc, then Tab, to leave the editor.
- 1Add
GET /users/{id}and read the parameter withr.PathValue. Try the wrong HTTP method and see the 405. - 2Write a handler that sets a header after
WriteHeaderand confirm the header is silently dropped. - 3Write an error path that forgets to
returnafter writing, then find "superfluous WriteHeader" in the log. - 4Swap
http.ListenAndServefor a configuredhttp.Serverwith all four timeouts.