HTML to PDF in Go
Go has no PDF renderer in its standard library, and the options in the ecosystem
divide into two uncomfortable groups. The drawing libraries — gofpdf, pdfcpu,
unipdf — want you to place text at coordinates, which is fine for a receipt and
miserable for anything a designer touches. The browser drivers — chromedp,
go-rod — give you real CSS, but now you operate Chromium: a few hundred megabytes
in the image, a sandbox to configure, zombie processes to reap, and a fresh set of
flags every time the base image changes.
Renderpaper is the second option without the operations. You POST HTML, you get a
PDF. The whole client is net/http.
One-off render
No SDK. This is the complete program.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body, err := json.Marshal(map[string]any{
"template": `<!doctype html>
<html><head><style>
@page { size: A4; margin: 20mm; }
body { font: 14px/1.6 system-ui, sans-serif; color: #16181d; }
h1 { font-size: 28px; margin: 0 0 4px; }
</style></head>
<body>
<h1>Invoice 2026-0142</h1>
<p>Issued 2026-08-07 · Due 2026-09-06</p>
</body></html>`,
})
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://renderpaper.com/v1/render", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("X-API-Key", os.Getenv("RENDER_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("render failed: %s: %s", resp.Status, msg))
}
out, err := os.Create("invoice.pdf")
if err != nil {
panic(err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
panic(err)
}
fmt.Println("wrote invoice.pdf")
}
Run it with your key in the environment:
RENDER_API_KEY=rs_live_… go run .
The response body is the PDF itself, so stream it — to a file, to w in an HTTP
handler, or to object storage. There is no base64 envelope to decode.
Stored templates: send data, not markup
The version above ships the whole document on every call. That is fine for one render and wrong for ten thousand invoices, because now your Go binary owns the design: changing a margin is a deploy.
Store the template once, then send only the data. The template is a Go
html/template — the syntax you already know.
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
)
type lineItem struct {
Description string `json:"Description"`
Quantity int `json:"Quantity"`
Amount float64 `json:"Amount"`
}
func main() {
payload, err := json.Marshal(map[string]any{
"data": map[string]any{
"InvoiceNumber": "2026-0142",
"Customer": map[string]string{"Name": "Northwind Trading AB"},
"LineItems": []lineItem{
{Description: "Consulting", Quantity: 12, Amount: 14400},
{Description: "Hosting", Quantity: 1, Amount: 4350},
},
"Total": 18750,
},
})
if err != nil {
panic(err)
}
const templateID = "your-template-id"
req, _ := http.NewRequest("POST",
"https://renderpaper.com/v1/templates/"+templateID+"/render",
bytes.NewReader(payload))
req.Header.Set("X-API-Key", os.Getenv("RENDER_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := os.Create("invoice.pdf")
defer out.Close()
io.Copy(out, resp.Body)
}
Inside the template that ID points at, the data is addressed exactly as you would in any Go template:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Now the person who owns the design changes the document in the editor, and your Go service keeps sending the same JSON. No deploy.
Serving a PDF from an HTTP handler
The common shape — a download endpoint that streams straight through, with no temporary file:
func (s *Server) invoicePDF(w http.ResponseWriter, r *http.Request) {
payload, err := json.Marshal(map[string]any{"data": s.invoiceData(r)})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
req, err := http.NewRequestWithContext(r.Context(), "POST",
"https://renderpaper.com/v1/templates/"+s.templateID+"/render",
bytes.NewReader(payload))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
req.Header.Set("X-API-Key", s.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
http.Error(w, "could not reach the renderer", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "render failed", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `attachment; filename="invoice.pdf"`)
io.Copy(w, resp.Body)
}
Passing r.Context() matters: a browser that gives up on the download cancels the
render too, instead of leaving it to finish for nobody.
Why not chromedp
chromedp is a good library and this is not a criticism of it. The tradeoff is
honest and it is about operations, not quality:
- Chromium in your image. A Go service that would ship in a 20MB scratch container becomes a few hundred megabytes, and the base image now has a CVE surface that updates on somebody else's schedule.
- Process lifecycle is yours. Crashed tabs, zombie processes, a memory ceiling that has to be enforced because one pathological document can take the box down.
- Concurrency is yours. Each render is a browser tab; deciding how many can run at once, and what happens to request 51, becomes your problem.
Run it yourself when you need the browser for other things anyway — scraping, screenshots, integration tests — or when documents must never leave your network. Both are real reasons.
Why not gofpdf or pdfcpu
They are the right answer when the document is generated entirely in code and never touched by a designer — a label, a barcode sheet, a fixed-format bank file. You get no browser, no network call, and a very small binary.
They are the wrong answer the moment somebody wants the header moved. There is no CSS: layout is coordinates and manual line breaking, and every visual change is a Go change, reviewed and deployed by an engineer.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, publicly served, no account.
The free tier is 50 documents a month with no card, which is enough to put a real integration in front of the person who has to approve the design.