Web
By the end of this lesson you'll be able to write a real HTTP server in Go with net/http — handle routes, read query and path parameters, return JSON, and set status codes and headers — using nothing but the standard library.
Part of the free Go course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • Register routes with http.HandleFunc and write a handler with the (w http.ResponseWriter, r *http.Request) signature
- • Start the server with http.ListenAndServe and understand why it blocks
- • Read query parameters with r.URL.Query().Get and path parameters with r.PathValue
- • Return JSON with encoding/json and json.NewEncoder(w).Encode(...)
- • Set status codes and headers with w.WriteHeader and w.Header().Set
- • Build an explicit router with http.ServeMux and method-based patterns
1️⃣ Your First Server: Handlers & ListenAndServe
A Go web server is built from two pieces. A handler is a function that takes a http.ResponseWriter (you write the reply into it) and a *http.Request (everything about the incoming call). You map a URL path to a handler with http.HandleFunc , then call http.ListenAndServe(":8080", nil) to start listening. Read this worked example first, then run it.
With the server running, open a second terminal and send it a request. curl is a command-line HTTP client — think of it as a browser you can script. Here's the request and the exact response your server returns:
2️⃣ Reading Query & Path Parameters
Most routes need input from the caller. A query parameter comes after the ? in the URL — read it with r.URL.Query().Get("name") , which returns "" if it's missing. A path parameter is part of the path itself; since Go 1.22 you capture it with a { placeholder } in the pattern and read it with r.PathValue("id") .
Your turn. The program below answers /square?n=5 , but two pieces are missing. Fill in each ___ using the // 👉 hints, then run it and send the request in the comment.
3️⃣ Returning JSON, Status Codes & Headers
Real APIs speak JSON. Define a struct with struct tags like to control the JSON keys, then write it with json.NewEncoder(w).Encode(value) . Two rules matter: set Content-Type to application/json before writing the body, and always check the error that Encode returns so a failure becomes a clean 500 instead of a half-written reply.
Now finish a JSON handler yourself. Three pieces are blanked out — the content type, the encoder package, and the route registration. Fill them in:
4️⃣ Routing with http.ServeMux
Passing nil to ListenAndServe uses a hidden global router. For anything real, create your own with http.NewServeMux() — it keeps every route in one place and is easy to test. Since Go 1.22 you can put the method in the pattern, like "GET /api/ping" , so a handler only runs for the method you intend. Set explicit status codes with w.WriteHeader(http.StatusCreated) — also before the body.
Common Errors (and the fix)
- Forgetting Content-Type for JSON: if you call Encode without w.Header().Set("Content-Type", "application/json") , Go guesses text/plain and browsers won't parse it as JSON. Set the header before the first write — afterwards it's ignored because the headers have already been sent.
- Not handling the Encode error: json.NewEncoder(w).Encode(v) returns an error . Ignoring it can leave a truncated, broken body with a misleading 200. Wrap it: if err := ...Encode(v); err != nil { http.Error(w, "...", 500); return } .
- Route not matching (404): a trailing-slash mismatch ( "/users" vs "/users/" ), a method that doesn't fit a "GET /path" pattern, or passing nil to ListenAndServe when your routes live on a custom mux. Register the exact pattern and pass your mux as the second argument.
Pro Tips
- 💡 Always check ListenAndServe 's error: log.Fatal(http.ListenAndServe(":8080", mux)) surfaces "address already in use" instead of exiting silently.
- 💡 Headers and status before body: set Content-Type and call WriteHeader first; the first write to w locks them in.
- 💡 Prefer an explicit ServeMux over the nil default — it's testable and keeps routing in one obvious place.
- 💡 Use struct tags for clean JSON: gives you snake_case keys without renaming Go fields.
📋 Quick Reference — net/http
Task
Go Syntax
Register a route
http.HandleFunc("/path", fn)
Handler signature
func(w http.ResponseWriter, r *http.Request)
Start the server
http.ListenAndServe(":8080", mux)
Write text
fmt.Fprintf(w, "Hi %s", name)
Query param
r.URL.Query().Get("name")
Path param
r.PathValue("id")
Set a header
w.Header().Set("Content-Type", "application/json")
Set status code
w.WriteHeader(http.StatusCreated)
Write JSON
json.NewEncoder(w).Encode(data)
New router
mux := http.NewServeMux()
Frequently Asked Questions
Mini-Challenge: A Tiny Status API
No blanks this time — just a brief and an outline. Build a one-route JSON API from scratch: define the struct, register the handler, set the header, encode (and handle the error), and start the server. Run it and check your response against the comment.
🎉 Course Complete — You Built Web Services in Go!
- ✅ A handler is func(w http.ResponseWriter, r *http.Request) — you write the reply into w
- ✅ http.HandleFunc maps a path to a handler; http.ListenAndServe starts the (blocking) server
- ✅ Read input with r.URL.Query().Get(...) (query) and r.PathValue(...) (path)
- ✅ Return JSON with encoding/json + json.NewEncoder(w).Encode(...) — and check the error
- ✅ Set headers and status before the body with w.Header().Set and w.WriteHeader
- ✅ Use http.ServeMux with method patterns like "GET /api/ping" for clean routing
- 🎓 That's the Go course! You've gone from package main to variables, functions, structs, interfaces, concurrency, errors, and now real web services.
- 👉 Where next: deepen your skills with Testing in Go to write unit and table-driven tests for the very handlers you just built. Then ship something real — try a small REST API backed by a database, and explore frameworks like Chi or Gin once you want route groups and middleware helpers.
Practice quiz
What is the correct Go HTTP handler signature?
- func(r *http.Request) http.Response
- func(req, res)
- func(w http.ResponseWriter, r *http.Request)
- func(ctx context.Context)
Answer: func(w http.ResponseWriter, r *http.Request). You write the reply into w; r holds the incoming request.
Which call maps a URL path to a handler function?
- http.HandleFunc("/path", fn)
- http.Route("/path", fn)
- http.Map("/path", fn)
- http.Get("/path", fn)
Answer: http.HandleFunc("/path", fn). HandleFunc registers a path-to-handler mapping.
Why is http.ListenAndServe usually the last line of main?
- It returns immediately
- It only sets up routes
- It runs in a goroutine automatically
- It blocks, running the server loop until the process stops
Answer: It blocks, running the server loop until the process stops. ListenAndServe blocks while serving requests.
How do you read a query parameter from /greet?name=Sam?
- r.PathValue("name")
- r.URL.Query().Get("name")
- r.Param("name")
- r.Query.name
Answer: r.URL.Query().Get("name"). Query().Get returns "" if the key is missing.
Since Go 1.22, how do you read a {id} path parameter?
- r.PathValue("id")
- r.URL.Query().Get("id")
- r.Path("id")
Answer: r.PathValue("id"). Patterns like /users/{id} expose r.PathValue("id").
When must you set the Content-Type header?
- After encoding the body
- Anytime before the handler returns
- Before the first write to the ResponseWriter
- It is set automatically and cannot change
Answer: Before the first write to the ResponseWriter. Headers are sent on the first write; later changes are ignored.
Which writes a struct to the response as JSON?
- w.Write(json(value))
- json.NewEncoder(w).Encode(value)
- fmt.Fprint(w, value)
- json.Print(w, value)
Answer: json.NewEncoder(w).Encode(value). NewEncoder(w).Encode streams JSON straight to the response.
What do struct tags like control?
- Validation rules
- Database columns
- The field's Go type
- The JSON key names for each field
Answer: The JSON key names for each field. Without tags, Go uses the exported Go field names as keys.
How do you send a 201 Created status before the body?
- w.Status(201)
- w.WriteHeader(http.StatusCreated)
- return 201
- w.Header().Set("Status", "201")
Answer: w.WriteHeader(http.StatusCreated). Call WriteHeader before writing the body; afterwards it is locked in.
Why prefer http.NewServeMux() over passing nil to ListenAndServe?
- It is faster at runtime
- nil is deprecated
- It keeps routes explicit and is easy to test
- It enables HTTPS automatically
Answer: It keeps routes explicit and is easy to test. A custom mux avoids the hidden global router and centralizes routing.
Continue this course
- Previous: Error Handling
- Next: Testing in Go