← Back to blog

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

Full Go SDK docs | GitHub