This episode dissects how to return responses in various formats: JSON, XML, YAML, TOML, and ProtoBuf. You'll also learn to render HTML templates, redirects, serve static files, handle file uploads, and stream responses.

After middleware is installed and requests are read successfully, it's time to decide what gets sent back to the client. This episode 7 dissects response rendering: how Gin turns Go data into JSON, XML, YAML, TOML, or ProtoBuf, how to render HTML templates, and how to handle files — static, upload, and streaming.
Why is this part important? Because API quality isn't measured only by routing speed, but also by consistent response formats and how assets are handled. An endpoint returning inconsistent formats will frustrate clients, while incorrect file handling can leak internal paths or strain server memory.
This episode is also a bridge: the concepts of content negotiation and streaming will be used again when we discuss SSE and WebSocket in episode 16.
Gin provides rendering methods for almost every popular format. All these methods take a status code as the first argument:
type Product struct {
Name string `json:"name" xml:"name"`
Price float64 `json:"price" xml:"price"`
}
p := Product{Name: "Kopi Nusantara", Price: 45000}
c.JSON(200, p)
c.IndentedJSON(200, p)
c.SecureJSON(200, p)
c.XML(200, p)
c.YAML(200, p)
c.TOML(200, p)
c.ProtoBuf(200, protoMessage)The call c.JSON(200, p) is the most common for REST APIs. SecureJSON adds a prefix to prevent XSSI attacks, while IndentedJSON displays human-readable output. Since Gin v1.12, c.TOML(200, p) and c.BSON(200, p) are also available for specific needs.
Gin can render complete HTML pages with Go's built-in template engine. Prepare a templates folder and call LoadHTMLGlob:
mkdir -p templatesr := gin.New()
r.LoadHTMLGlob("templates/*")
r.GET("/", func(c *gin.Context) {
c.HTML(200, "index.html", gin.H{
"title": "Halaman Beranda",
"user": "Arman",
})
})The function r.LoadHTMLGlob("templates/*") loads all files in the templates folder. The template name used by c.HTML(200, "index.html", ...) is the file name itself. If you use static files for templates, use LoadHTMLFiles with an explicit list of paths.
A single endpoint can serve multiple formats at once based on the client's Accept header:
func getUser(c *gin.Context) {
user := gin.H{"id": 1, "name": "Arman"}
switch c.NegotiateFormat(gin.MIMEJSON, gin.MIMEXML, gin.MIMEYAML) {
case gin.MIMEJSON:
c.JSON(200, user)
case gin.MIMEXML:
c.XML(200, user)
case gin.MIMEYAML:
c.YAML(200, user)
default:
c.JSON(200, user)
}
}c.NegotiateFormat(gin.MIMEJSON, ...) returns the first format matching the Accept header. If nothing matches, it returns an empty string so you can provide a JSON fallback.
For assets like CSS, JavaScript, and images, use the Static methods:
r.Static("/static", "./public")
r.StaticFile("/favicon.ico", "./public/favicon.ico")
r.StaticFileFS("/assets", "./public", http.Dir("./public"))r.Static("/static", "./public") maps all contents of the ./public folder to URLs starting with /static. Directory listing is disabled automatically, and Strict-Transport-Security doesn't need to be set here because that's an HTTPS concern (episode 15).
To send a single file as a response, for example a server-generated report:
r.GET("/laporan/:id", func(c *gin.Context) {
c.FileAttachment("./storage/laporan.pdf", "laporan.pdf")
})c.FileAttachment("./storage/laporan.pdf", "laporan.pdf") sends a file while setting the Content-Disposition header so the browser offers a download dialog. Use c.File(path) if you want the file displayed inline in the browser.
For uploads, HTML forms use enctype="multipart/form-data". On the handler side:
r.POST("/upload", func(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "file wajib dikirim"})
return
}
dst := "./storage/" + file.Filename
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "upload berhasil", "name": file.Filename})
})c.FormFile("file") reads the file from the multipart form, then c.SaveUploadedFile(file, dst) saves it to disk. Limit the upload size with r.MaxMultipartMemory = 8 << 20 to prevent running out of memory.
Test it with curl:
curl -X POST -F "file=@laporan.txt" http://localhost:8080/uploadFor large responses without holding everything in memory, Gin provides c.DataFromReader:
file, err := os.Open("./data/besar.csv")
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer file.Close()
info, _ := file.Stat()
c.DataFromReader(200, info.Size(), "text/csv", file, nil)c.DataFromReader(200, ...) writes the body incrementally from an io.Reader. This way, a file of any size is never fully loaded into memory. For one-way realtime streaming, Gin also has c.SSEvent, which we'll cover in depth in episode 16.
Key takeaways:
c.JSON, c.XML, c.YAML, c.TOML, and c.ProtoBuf for formatted responses.LoadHTMLGlob + c.HTML to render HTML template pages.c.Redirect for redirects; c.NegotiateFormat for content negotiation.r.Static and c.FileAttachment for static files and downloads.c.FormFile + c.SaveUploadedFile to accept uploads.c.DataFromReader for streaming responses without loading everything into memory.In the next episode, episode 8, we'll dissect project structure & clean architecture — organizing code into handlers, services, and repositories, using the internal folder, simple dependency injection, and separating the router setup so it's easy to test and scale.