PDF generation in Go without cgo or external processes
Generate PDFs from Go with a zero-dependency API client or local WASM rendering via wazero. No Puppeteer, no LaTeX, no cgo.
Most Go PDF libraries fall into two camps: low-level coordinate drawing (gofpdf, pdfcpu) where you manually position every element, or HTML-to-PDF converters that shell out to Chrome or wkhtmltopdf. Neither gives you a component model with flexbox, tables that repeat headers across pages, or real page-aware layout.
Forme now has a Go SDK.
Two ways to use it
Hosted API — zero dependencies, one HTTP call:
package main
import (
"os"
forme "github.com/formepdf/forme-go"
)
func main() {
client := forme.New(os.Getenv("FORME_API_KEY"))
pdf, err := client.Render("invoice", map[string]any{
"customer": "Acme Corp",
"items": []map[string]any{
{"name": "Widget", "qty": 5, "price": 49},
},
"total": 245,
})
if err != nil {
panic(err)
}
os.WriteFile("invoice.pdf", pdf, 0644)
}
You define templates once (in JSX or JSON), deploy them to the hosted API, and render with data from Go. The client is stdlib-only — no third-party dependencies.
Local rendering — build documents in Go, render with WASM:
package main
import (
"os"
t "github.com/formepdf/forme-go/templates"
)
func main() {
doc := t.Document(
t.Page(
t.View(
t.Text("Invoice #001", t.Style{FontSize: 24, FontWeight: "bold"}),
t.Text("Acme Corp", t.Style{FontSize: 14, Color: "#666"}),
).Style(t.Style{FlexDirection: "column", Gap: 8}),
t.Table(
t.Row(t.Cell(t.Text("Item")), t.Cell(t.Text("Price"))).Header(true),
t.Row(t.Cell(t.Text("Widget")), t.Cell(t.Text("$50.00"))),
t.Row(t.Cell(t.Text("Gadget")), t.Cell(t.Text("$100.00"))),
).Columns([]t.Column{{Width: "1fr"}, {Width: 100.0}}),
),
).Title("Invoice #001")
pdf, err := doc.Render()
if err != nil {
panic(err)
}
os.WriteFile("invoice.pdf", pdf, 0644)
}
The component DSL mirrors the JSX API. doc.Render() runs the Rust engine through WASM via wazero — no cgo, no subprocess, no network call. Pure Go + WASM.
Why not gofpdf?
gofpdf is the most popular Go PDF library. It works, but you're drawing with coordinates:
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 16)
pdf.Text(10, 20, "Invoice #001")
pdf.Text(10, 30, "Customer: Acme Corp")
// Add items... but where? You have to track Y manually.
// Overflow a page? You write your own pagination.
Add a line item and everything below it needs to shift. Overflow a page and you're writing pagination logic by hand. gofpdf has no layout engine — it's a PDF drawing API.
Forme uses flexbox. Add content and the layout adjusts. Content overflows a page and the engine handles the break, including repeating table headers on continuation pages.
Why not chromedp / headless Chrome?
Shelling out to Chrome from Go is common but heavy:
- Chrome process uses 100+ MB of RAM per render
- Cold start is measured in seconds
- You need Chrome installed in your container
- Page breaks are CSS-based and break with nested flex layouts
Forme's engine is a 10MB WASM binary that loads once and renders in milliseconds. No Chrome, no cgo, no system dependencies. Via wazero, the WASM runs in a pure Go sandbox.
Components
The templates package includes the full component set:
| Constructor | Description |
|---|---|
Document(children...) |
Root container with metadata |
Page(children...) |
Page with size and margins |
View(children...) |
Flex/grid container |
Text(content, style?) |
Text with styles |
Image(src) |
JPEG, PNG, WebP, or data URI |
Table(children...) |
Tables with auto-repeating headers |
Row(children...) / Cell(children...) |
Table structure |
QRCode(data) |
Vector QR codes |
Barcode(data) |
Code128, Code39, EAN-13, EAN-8, Codabar |
BarChart(data) |
Bar chart |
LineChart(series, labels) |
Line chart |
PieChart(data) |
Pie/donut chart |
Watermark(text) |
Rotated text behind content |
All components support .Style(t.Style{...}) for flexbox, grid, typography, borders, colors, and spacing.
Tables that break across pages
rows := make([]t.NodeBuilder, 0, 52)
rows = append(rows,
t.Row(
t.Cell(t.Text("Product", t.Style{FontWeight: "bold"})),
t.Cell(t.Text("Price", t.Style{FontWeight: "bold"})),
).Header(true),
)
for i := 1; i <= 50; i++ {
rows = append(rows,
t.Row(
t.Cell(t.Text(fmt.Sprintf("Item %d", i))),
t.Cell(t.Text(fmt.Sprintf("$%d.00", i*10))),
),
)
}
doc := t.Document(
t.Page(t.Table(rows...).Columns([]t.Column{{Width: "1fr"}, {Width: 100.0}})),
)
pdf, _ := doc.Render()
The table splits across pages automatically. The header row ("Product" / "Price") repeats at the top of every page. No manual pagination.
Hosted vs native
| Hosted API | Native templates | |
|---|---|---|
| Dependencies | Zero (stdlib only) | wazero (~10MB WASM) |
| Templates | Pre-uploaded via dashboard | Built in Go code |
| Network | Requires API call | Fully offline |
| Use case | Dynamic data + stored templates | Full control, CI/CD, testing |
| Rendering | Server-side | Local WASM |
Install
# API client
go get github.com/formepdf/forme-go
# Native templates
go get github.com/formepdf/forme-go/templates