boxpdf

Declarative PDFs
that run anywhere.

A tiny box-layout DSL over pdf-lib. Flexbox-lite for server-side PDF generation in Node, Cloudflare Workers, Deno, and the browser. No WASM. No headless browser. No React.

import { cleanTheme, hline, hstack, renderFlow, text, vstack } from "boxpdf";
import { PDFDocument, StandardFonts } from "pdf-lib";

const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const theme = cleanTheme(font, bold);

await renderFlow(pdf, [
  vstack({ gap: 8 },
    text("Receipt #18472", theme.type.h1),
    text("May 14, 2026",     theme.type.caption)
  ),
  hline(theme.hr),
  hstack({ gap: 16 },
    text("Wool socks",   theme.type.body),
    text("$28.00",        { ...theme.type.body, font: bold, align: "right", width: 80 })
  )
]);

const bytes = await pdf.save(); // -> Uint8Array

What's in the box

Runs at the edge

Works on Cloudflare Workers without nodejs_compat or any WASM flag. Verified end-to-end with embedded Inter.

Flex-ish layout, no coordinates

vstack / hstack with padding, margin, gap, justify, align, grow, shrink. Real word-wrapping and ellipsis.

Streaming output

streamFlow emits bytes to a WritableStream as each page closes. Peak heap stays flat at any page count. A 1000-page report uses 5× less memory than renderFlow + pdf.save().

Four named themes

Drop-in cleanTheme, stripeTheme, editorialTheme, brutalistTheme. Same code restyles every template.

Multi-page flow

renderFlow paginates with atomic children. keepTogether bundles widows. Page headers and footers with { pageNumber, totalPages } built in.

Bring your own fonts

Optional boxpdf/inter ships subsetted Inter (82 KB / weight). loadFont(pdf, source) and the boxpdf font add CLI bundle any TTF as base64.

Hyperlinks, decorations, metadata

link({ href }, ...), underline, strikethrough, title / author / subject as renderFlow options.

Debug overlay

renderFlow(pdf, nodes, { debug: true }) outlines every content and margin box in red and orange. Trace layouts visually.

Tiny, tree-shakable

Core is <7 KB minified. boxpdf/inter and @pdf-lib/fontkit only load when you use them.

Four named themes

Same layout code, four aesthetics. Swap the theme and every template restyles.

Clean theme. Modern SaaS, soft borders, 8pt rounded corners
cleanTheme modern SaaS default
Stripe theme. Square corners, thin borders, monochrome with accent
stripeTheme square corners, thin borders, monochrome
Editorial theme. Times serif, warm cream, italic captions
editorialTheme Times serif, generous leading
Brutalist theme. Courier monospace, 2pt solid black borders
brutalistTheme Courier monospace, 2pt black borders

Template gallery

Production-ready documents in templates/. Copy a file, edit it for your data, ship it.

Receipt template
Receipt cleanTheme · 1 page templates/receipt.ts
Boarding pass template
Boarding pass cleanTheme · 1 page templates/boarding-pass.ts
Resume template
Resume editorialTheme · 2 pages templates/resume.ts
Order confirmation template
Order confirmation cleanTheme · 1 page templates/order-confirmation.ts
Certificate template
Certificate editorialTheme · landscape templates/certificate.ts
Multi-page invoice with page headers and footers
Invoice cleanTheme · 2 pages, header/footer, keepTogether examples/invoice.ts
Travel itinerary with two flight bands
Itinerary cleanTheme · two-band layout examples/itinerary.ts
Debug overlay with red content boxes and orange margin boxes
Debug overlay demo cleanTheme · { debug: true } examples/debug.ts

Install

npm install boxpdf pdf-lib

pdf-lib is a peer dependency. If you want custom-font embedding (incl. boxpdf/inter), @pdf-lib/fontkit comes along for the ride and is lazy-loaded only when you embed a non-standard font.

Cloudflare Workers

Both the core and the boxpdf/inter subpath are verified to run on Cloudflare Workers without nodejs_compat or any WASM flag. Drop it into a worker handler:

import { Hono } from "hono";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { cleanTheme, renderFlow, text, vstack } from "boxpdf";

const app = new Hono();

app.get("/receipt.pdf", async (c) => {
  const pdf  = await PDFDocument.create();
  const font = await pdf.embedFont(StandardFonts.Helvetica);
  const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
  const t    = cleanTheme(font, bold);
  await renderFlow(pdf, [
    text("Thanks!", t.type.h1),
    text("This PDF was generated at the edge.", t.type.body)
  ]);
  const bytes = await pdf.save();
  return new Response(bytes, { headers: { "content-type": "application/pdf" } });
});

export default app;

How it compares

boxpdfpdf-lib@react-pdf/rendererjsPDF
Declarative layout✓ (JSX)
Cloudflare Workers✗ (fontkit WASM)partial
Streaming outputstreamFlow (Web Writable, bounded memory)Node only
Custom fonts✓ via fontkit (lazy)✓ via fontkitlimited
Core bundle~7 KB gz~250 KB gz~250 KB gz~80 KB gz
JSX runtime conflictnonen/arequires Reactn/a

Sizes are approximate. Measure yourself.

Memory bench

Peak heap during render. 50 lines of text per page. Each measurement runs in its own subprocess. @react-pdf/renderer uses Standard Helvetica (no font embedding) to match the boxpdf paths.

Peak heap during render across page counts for streamFlow, renderFlow, and @react-pdf/renderer

PagesstreamFlowrenderFlow@react-pdf/rendererOutput
5012.8 MB31.7 MB160.8 MB70 KB
25015.4 MB91.1 MB643.1 MB347 KB
50018.7 MB120.8 MB1,219.9 MB693 KB
100025.4 MB219.6 MB2,292.6 MB1.4 MB

Bench source: scripts/bench-memory.ts. Design doc: docs/design/streaming.md.

Shipped in 1.4