This episode focuses on the response side: clean JSON helpers, HTML rendering, redirects, and streaming responses. You will also learn to serve static files with http.FileServer, handle file uploads, and write custom response writers for special cases.

A good handler doesn't just read requests — it also sends correct responses. Episode 7 teaches the whole output side: consistent JSON, HTML, redirects, streaming, and static files.
Many Go applications fail because their JSON responses aren't uniform: sometimes the status is wrong, sometimes the content type is forgotten. This episode gives you a consistent pattern to use across all your projects.
Writing four lines of JSON encoding in every handler invites inconsistency. Build the helper once, use it everywhere:
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}writeJSON(w, http.StatusCreated, user) sets the header, the status, then encodes in a single call. The status int and data any parameters let this helper handle both success and error cases.
func createUserHandler(w http.ResponseWriter, req *http.Request) {
var input User
if err := json.NewDecoder(req.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest,
map[string]string{"error": "body tidak valid"})
return
}
writeJSON(w, http.StatusCreated, input)
}json.NewDecoder(req.Body).Decode(&input) reads the JSON body from the request, then the helper sends a 201 Created response with the decoded data.
For simple pages, use html/template:
tpl := template.Must(template.ParseFiles("templates/home.html"))
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tpl.Execute(w, map[string]string{"Nama": "Budi"})
})tpl.Execute(w, data) writes the templated HTML directly to the ResponseWriter. Avoid fmt.Fprintf for untrusted data because it's prone to injection — html/template escapes automatically.
r.Get("/old", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/new", http.StatusMovedPermanently)
})http.Redirect(w, req, "/new", http.StatusMovedPermanently) sends status 301 and the header Location: /new. Browsers and curl will follow it automatically.
For continuously flowing data, enable flush:
r.Get("/stream", func(w http.ResponseWriter, req *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming tidak didukung",
http.StatusInternalServerError)
return
}
for i := 0; i < 5; i++ {
fmt.Fprintf(w, "chunk %d\n", i)
flusher.Flush()
time.Sleep(500 * time.Millisecond)
}
})flusher.Flush() forces data to be sent to the client before the handler finishes — the basic Server-Sent Events pattern we'll build in episode 16.
Serve a static directory with the classic combination:
fileServer := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static", fileServer))http.StripPrefix("/static", fileServer) strips the /static prefix from the request so /static/css/app.css reads the file ./static/css/app.css. The r.Handle("/static/*", ...) pattern captures all sub-paths.
An equivalent and more concise approach:
r.Mount("/static", http.StripPrefix("/static",
http.FileServer(http.Dir("./static"))))r.Mount("/static", handler) attaches the file server to a prefix — the result is the same as the Handle pattern, just written more briefly.
Accept files from clients with ParseMultipartForm:
r.Post("/upload", func(w http.ResponseWriter, req *http.Request) {
req.ParseMultipartForm(10 << 20)
file, handler, err := req.FormFile("foto")
if err != nil {
http.Error(w, "file tidak ditemukan", http.StatusBadRequest)
return
}
defer file.Close()
dst, _ := os.Create("./uploads/" + handler.Filename)
defer dst.Close()
io.Copy(dst, file)
writeJSON(w, http.StatusOK,
map[string]string{"message": "upload sukses"})
})req.FormFile("foto") grabs the file from the foto field in the multipart body. The 10 << 20 limit caps the maximum body size at 10 MiB.
Sometimes you need to peek at what the handler writes — for example, for status code logging:
type statusWriter struct {
http.ResponseWriter
status int
}
func (sw *statusWriter) WriteHeader(code int) {
sw.status = code
sw.ResponseWriter.WriteHeader(code)
}statusWriter wraps the original ResponseWriter and records the status code. chi even provides middleware.WrapResponseWriter for this purpose — we'll use it again in episodes 11 and 20.
Key takeaways:
writeJSON helper keeps JSON responses consistent across all handlers.html/template escapes automatically; http.Redirect for redirects.flusher.Flush() opens the door to streaming.http.FileServer plus StripPrefix serves static files.req.FormFile with a body size limit.In the next episode 8 we go up a level: project structure and clean architecture — separating handlers, services, and repositories, using the internal layout, dependency injection, and defining the router as a function for easy testing.