Renderpaper
Log in Start free

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:

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.

Last updated 2026-08-07.