Generate PDFs on Cloudflare Workers
PDF generation at the edge. No headless Chrome, no servers, no cold starts. Just WASM running in 200+ data centers.
You can't run Puppeteer on Cloudflare Workers. Chromium is 200MB and Workers have a bundle size limit. Even if you could fit it, you can't spawn child processes in the V8 isolate. The same goes for Deno Deploy, Vercel Edge Functions, and every other edge runtime.
So if you need to generate a PDF on the edge, you've been stuck. Until now.
import { Hono } from 'hono';
import { formePdf } from '@formepdf/hono';
const app = new Hono();
app.use(formePdf());
app.get('/invoice/:id', async (c) => {
const invoice = await c.env.DB.prepare(
'SELECT * FROM invoices WHERE id = ?'
).bind(c.req.param('id')).first();
return c.pdf('invoice', {
company: 'Acme Corp',
customer: invoice.customer_name,
invoiceNumber: invoice.number,
items: JSON.parse(invoice.line_items),
total: invoice.total,
});
});
export default app;
Deploy with wrangler deploy. You now have a PDF API running at the edge.
Why this works
Forme's PDF engine is written in Rust and compiles to WebAssembly. WASM runs anywhere JavaScript runs: Node.js, Bun, Deno, and Cloudflare Workers. The engine is about 3MB, well within Workers' limits. It doesn't shell out to Chrome, doesn't spawn processes, doesn't touch the filesystem. It takes a JSON document description and returns PDF bytes. Pure computation.
The Hono middleware adds a c.pdf() method to the context. You give it a template name and data, it renders the PDF and returns a Response with the correct content type and disposition headers. The Response uses the standard Web API, which is what Workers expect.
Performance
An edge function generating PDFs sounds like it might be slow. It's not.
A typical invoice renders in 20-40ms. That's the Rust layout engine doing its thing: parsing the document, calculating flex layout, flowing content across pages, subsetting fonts, and writing the PDF binary. No network round trips, no browser startup.
For a user in Tokyo hitting a Workers endpoint, that's a PDF generated in the nearest data center in under 50ms. Compare that to a centralized Puppeteer server in us-east-1 where the browser launch alone takes longer than the total edge render time.
The full Workers setup
Here's a complete example: a PDF API backed by D1 (Cloudflare's SQLite database).
// src/index.ts
import { Hono } from 'hono';
import { formePdf } from '@formepdf/hono';
import { bearerAuth } from 'hono/bearer-auth';
type Env = {
DB: D1Database;
API_TOKEN: string;
};
const app = new Hono<{ Bindings: Env }>();
app.use('/api/*', bearerAuth({ token: (c) => c.env.API_TOKEN }));
app.use(formePdf());
app.get('/api/invoice/:id', async (c) => {
const row = await c.env.DB.prepare(
'SELECT * FROM invoices WHERE id = ?'
).bind(c.req.param('id')).first();
if (!row) return c.json({ error: 'Not found' }, 404);
return c.pdf('invoice', {
company: row.company_name,
customer: row.customer_name,
invoiceNumber: row.invoice_number,
date: row.created_at,
items: JSON.parse(row.items),
subtotal: row.subtotal,
tax: row.tax,
total: row.total,
}, { filename: `invoice-${row.invoice_number}.pdf` });
});
app.get('/api/receipt/:id', async (c) => {
const row = await c.env.DB.prepare(
'SELECT * FROM receipts WHERE id = ?'
).bind(c.req.param('id')).first();
if (!row) return c.json({ error: 'Not found' }, 404);
return c.pdf('receipt', {
company: row.company_name,
customer: row.customer_name,
items: JSON.parse(row.items),
total: row.total,
paymentMethod: row.payment_method,
date: row.created_at,
});
});
export default app;
# wrangler.toml
name = "pdf-api"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "invoices"
database_id = "your-database-id"
That's a production PDF API. Auth, database, two endpoints, edge deployment. The entire thing is about 50 lines.
Without middleware
If you prefer not to use middleware, there's a standalone pdfResponse function:
import { pdfResponse } from '@formepdf/hono';
app.get('/invoice/:id', async (c) => {
const data = await fetchInvoiceData(c.req.param('id'));
return pdfResponse('invoice', data, { download: true });
});
Same result, just a function call instead of a context method.
Custom templates
The built-in templates handle standard documents. For anything custom, pass a render function:
import { PackingSlip } from './templates/packing-slip';
app.get('/orders/:id/packing-slip', async (c) => {
const order = await getOrder(c.req.param('id'));
return c.pdf(() => PackingSlip(order), {
filename: `packing-slip-${order.number}.pdf`,
});
});
Where this matters
Edge PDF generation isn't just a technical curiosity. It changes what's possible:
A SaaS product can generate customer-facing documents without maintaining a render server. A global marketplace can produce invoices close to the buyer, not in a single region. An API product can offer PDF exports without adding Puppeteer to the infrastructure budget.
If your app runs on Workers, your PDFs can too.
Install
npm install @formepdf/hono @formepdf/react @formepdf/core hono