HTML to PDF
in Go.
Go's appeal is one static binary with no runtime. Embedding Chrome to make a PDF throws that away. An HTTP call does not.
The call
Post your markup with an API key. The PDF comes back in the same response, base64 encoded by default. Nothing is written to disk on our side.
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"os"
)
type result struct {
PDF string `json:"pdf"`
CreditsRemaining int `json:"creditsRemaining"`
}
func main() {
body, _ := json.Marshal(map[string]any{
"html": "<h1>Invoice INV-1042</h1><p>Total: $1,645.40</p>",
"options": map[string]any{"format": "A4", "printBackground": true},
})
req, _ := http.NewRequest("POST", "https://api.html4pdf.com/generate-pdf", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HTML4PDF_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var out result
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
log.Fatal(err)
}
pdf, _ := base64.StdEncoding.DecodeString(out.PDF)
if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
log.Fatal(err)
}
}Serving it from a handler
With a binary response you can copy the body straight to the response writer, so nothing is buffered twice.
func invoicePDF(w http.ResponseWriter, r *http.Request) {
body, _ := json.Marshal(map[string]any{
"html": renderInvoice(r.PathValue("id")),
"responseFormat": "binary",
})
req, _ := http.NewRequest("POST", "https://api.html4pdf.com/generate-pdf", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HTML4PDF_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "pdf failed", http.StatusBadGateway)
return
}
defer res.Body.Close()
w.Header().Set("Content-Type", "application/pdf")
io.Copy(w, res.Body)
}What comes back
The default response is JSON with the document base64 encoded, the credits you have left, and how long the render took. Send responseFormat as binary instead and the body is the PDF itself, with a Content-Type of application/pdf.
{
"success": true,
"pdf": "JVBERi0xLjQKJeLjz9MKMyAwIG9...",
"creditsRemaining": 9,
"metadata": { "size": 42813, "processingTime": 2841 }
}A 402 means you are out of credits, a 401 means the key is wrong, and a 422 means the markup could not be rendered. Everything else is worth retrying once.
The usual Go libraries
Worth knowing before you add a dependency, because for a lot of documents one of these is the right answer and costs nothing.
| Library | What it is | The trade-off |
|---|---|---|
| chromedp | Drives Chrome over the DevTools protocol from Go. | Exact rendering and full control. Your container now contains Chrome, and your deployment inherits its memory profile. |
| gofpdf | A port of FPDF that draws the document in code. | No dependencies, no HTML. The original repository is archived, so maintenance is community forks. |
| Maroto | A grid-based layout wrapper over a drawing library. | Pleasant for reports with repeating rows. Still code, not markup. |
Go: common questions
How do I convert HTML to PDF in Go without Chrome?
Post the HTML to a service that runs Chrome and write the bytes it returns. Your binary stays static, with no cgo and no browser in the image.
Does it need cgo?
No. It is net/http and encoding/json from the standard library.
How long does a request take?
Three to five seconds for a typical document. Set a client timeout of 60 seconds to be safe.
Can I stream the PDF to the client?
Yes. Ask for responseFormat binary and io.Copy the response body into your ResponseWriter.