Guide
Generate a PDF from HTML in Go
You have an HTTP handler, some HTML (an invoice, a report, a contract), and you need to return a PDF. The usual answers in Go are driving headless Chrome with chromedp or shelling out to a wkhtmltopdf binary. This guide shows the lighter path: one net/http request to a rendering API, streamed straight back to the client.
The minimal handler
The PDFPipe endpoint is POST https://api.pdfpipe.xyz/v1/pdf. Send a JSON body with your HTML and options, attach a bearer token, and you get back raw application/pdf bytes. Here is a complete handler using only the standard library.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
var client = &http.Client{Timeout: 30 * time.Second}
func invoiceHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
html := fmt.Sprintf(`
<html>
<body style="font-family: sans-serif; padding: 40px">
<h1>Invoice #%s</h1>
<p>Amount due: $420.00</p>
</body>
</html>`, id)
payload, _ := json.Marshal(map[string]any{
"html": html,
"options": map[string]string{"format": "A4"},
})
req, _ := http.NewRequestWithContext(r.Context(),
http.MethodPost, "https://api.pdfpipe.xyz/v1/pdf", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("PDFPIPE_KEY"))
resp, err := client.Do(req)
if err != nil {
http.Error(w, "pdf render failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(resp.Body)
http.Error(w, "pdf render failed: "+string(detail), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition",
fmt.Sprintf("inline; filename=%q", "invoice-"+id+".pdf"))
io.Copy(w, resp.Body) // stream, no buffering
}
func main() {
http.HandleFunc("GET /invoice/{id}", invoiceHandler)
http.ListenAndServe(":8080", nil)
}That is the whole feature. No browser binary in the container, no CGO, no zombie Chrome processes. io.Copy streams the PDF through your handler without ever holding the full document in memory, and passing r.Context() means a client disconnect cancels the upstream render too.
Why not chromedp or wkhtmltopdf?
Both are fine tools with real costs in a service. chromedp needs a Chrome or Chromium install in your image and burns hundreds of MB of memory per render. wkhtmltopdf is a 2015-era WebKit frozen in time: flexbox and modern CSS silently break, and the project is archived.
| Concern | chromedp / wkhtmltopdf | net/http to PDFPipe |
|---|---|---|
| Image size | Chrome adds ~280 MB; wkhtmltopdf needs Qt libs | Zero, it is one HTTP call |
| Static binary deploys | Broken, you now ship a browser beside your binary | Preserved, scratch images keep working |
| Modern CSS | wkhtmltopdf fails on flexbox and grid | Current engine, renders what your browser renders |
| Memory per render | A browser tab, often 200+ MB | A streamed response body |
Timeouts and retries
Treat the render like any upstream dependency: bound it with a context deadline and retry once on transient failure. Renders are idempotent, so a retry is always safe.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
// Idempotent render: one retry on 5xx or transport error is safe.
resp, err := client.Do(req)
if err != nil || resp.StatusCode >= 500 {
resp, err = client.Do(req)
}Keep the API key in an environment variable. The options object also accepts other page sizes (for example Letter), margins, and landscape orientation, documented in full on the docs page.
Paste your own HTML into the live playground on the home page to see the PDF render instantly, then read the docs for every option.
See pricing →