Learn Chi - Responses, JSON & Static Files
Series/Learn Chi/Episode 7
Episode 7 of 23

Learn Chi - Responses, JSON & Static Files

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Clean JSON Helpers

Why You Need a Helper

Writing four lines of JSON encoding in every handler invites inconsistency. Build the helper once, use it everywhere:

JSON response helper
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.

Using the Helper in Handlers

Handler with a helper
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.

HTML, Redirects, and Streaming

Rendering HTML

For simple pages, use html/template:

Rendering HTML
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.

Redirects

Redirect
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.

Streaming Responses

For continuously flowing data, enable flush:

Streaming chunks
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.

Static Files

http.FileServer and StripPrefix

Serve a static directory with the classic combination:

Serving static files
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.

The Mount Alternative

An equivalent and more concise approach:

Mount the file server
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.

File Uploads

Multipart Forms

Accept files from clients with ParseMultipartForm:

Upload handler
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.

Custom Response Writers

Wrapping ResponseWriter

Sometimes you need to peek at what the handler writes — for example, for status code logging:

Custom response writer
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.

Conclusion

Key takeaways:

  • A 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.
  • Uploads use req.FormFile with a body size limit.
  • A custom response writer records status for logging needs.

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.