Generating invoices on Cloudflare Workers with Hono and D1
Build a complete invoice generation API on Cloudflare Workers using Hono, D1, and Forme. No Puppeteer, no Chrome, no cold starts.
Cloudflare Workers is one of the best places to run a PDF generation API. Zero cold starts, global edge deployment, and a generous free tier. The problem has always been that you can't run Puppeteer there — Chrome is 200MB and Workers have a 10MB bundle limit.
Forme's WASM engine is 3MB. It runs on Workers natively.
This post walks through building a complete invoice generation API using Hono, D1, and Forme — where invoices are stored in a D1 database and rendered to PDF on demand at the edge.
What we're building
An API with two endpoints:
POST /invoices— create an invoice record in D1GET /invoices/:id/pdf— render and return the invoice as a PDF
The PDF is generated fresh on each request using data from D1. No storing PDFs, no S3, no background jobs. Just fast edge rendering.
Setup
npm create cloudflare@latest invoice-api -- --type hono
cd invoice-api
npm install @formepdf/hono @formepdf/react @formepdf/core
Add D1 to your wrangler.toml:
name = "invoice-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "invoices"
database_id = "your-database-id"
Create the database:
npx wrangler d1 create invoices
Database schema
CREATE TABLE invoices (
id TEXT PRIMARY KEY,
company_name TEXT NOT NULL,
company_address TEXT,
client_name TEXT NOT NULL,
client_company TEXT,
client_email TEXT,
invoice_number TEXT NOT NULL,
date TEXT NOT NULL,
due_date TEXT NOT NULL,
items TEXT NOT NULL, -- JSON array
tax_rate REAL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Apply it:
npx wrangler d1 execute invoices --file schema.sql
The invoice template
Create your invoice template using Forme's JSX component model:
// src/templates/invoice.tsx
import { Document, Page, View, Text, Table, Row, Cell } from '@formepdf/react';
interface Item {
description: string;
quantity: number;
unitPrice: number;
}
interface InvoiceData {
companyName: string;
companyAddress: string;
clientName: string;
clientCompany: string;
invoiceNumber: string;
date: string;
dueDate: string;
items: Item[];
taxRate: number;
}
export default function Invoice({ data }: { data: InvoiceData }) {
const subtotal = data.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
const tax = subtotal * data.taxRate;
const total = subtotal + tax;
return (
<Document>
<Page size="Letter" margin={48}>
{/* Header */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 40 }}>
<View>
<Text style={{ fontSize: 20, fontWeight: 700 }}>{data.companyName}</Text>
<Text style={{ fontSize: 10, color: '#666', marginTop: 4 }}>
{data.companyAddress}
</Text>
</View>
<Text style={{ fontSize: 32, fontWeight: 700, color: '#2563eb' }}>
INVOICE
</Text>
</View>
{/* Invoice details */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 32 }}>
<View>
<Text style={{ fontSize: 10, color: '#999', marginBottom: 4 }}>BILL TO</Text>
<Text style={{ fontSize: 12, fontWeight: 700 }}>{data.clientName}</Text>
<Text style={{ fontSize: 10, color: '#666' }}>{data.clientCompany}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={{ fontSize: 10, color: '#666' }}>Invoice No: {data.invoiceNumber}</Text>
<Text style={{ fontSize: 10, color: '#666' }}>Date: {data.date}</Text>
<Text style={{ fontSize: 10, color: '#666' }}>Due: {data.dueDate}</Text>
</View>
</View>
{/* Line items */}
<Table columns={[{ width: { fraction: 1 } }, { width: { fixed: 80 } }, { width: { fixed: 80 } }, { width: { fixed: 80 } }]}>
<Row header>
<Cell><Text style={{ fontWeight: 700, fontSize: 10 }}>Description</Text></Cell>
<Cell><Text style={{ fontWeight: 700, fontSize: 10 }}>Qty</Text></Cell>
<Cell><Text style={{ fontWeight: 700, fontSize: 10 }}>Unit Price</Text></Cell>
<Cell><Text style={{ fontWeight: 700, fontSize: 10 }}>Amount</Text></Cell>
</Row>
{data.items.map((item, i) => (
<Row key={i}>
<Cell><Text style={{ fontSize: 10 }}>{item.description}</Text></Cell>
<Cell><Text style={{ fontSize: 10 }}>{item.quantity}</Text></Cell>
<Cell><Text style={{ fontSize: 10 }}>${item.unitPrice.toFixed(2)}</Text></Cell>
<Cell>
<Text style={{ fontSize: 10 }}>
${(item.quantity * item.unitPrice).toFixed(2)}
</Text>
</Cell>
</Row>
))}
</Table>
{/* Totals */}
<View style={{ alignItems: 'flex-end', marginTop: 16 }}>
<View style={{ width: 200 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ fontSize: 10, color: '#666' }}>Subtotal</Text>
<Text style={{ fontSize: 10 }}>${subtotal.toFixed(2)}</Text>
</View>
{data.taxRate > 0 && (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ fontSize: 10, color: '#666' }}>
Tax ({(data.taxRate * 100).toFixed(0)}%)
</Text>
<Text style={{ fontSize: 10 }}>${tax.toFixed(2)}</Text>
</View>
)}
<View style={{
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#2563eb',
padding: 8,
borderRadius: 4,
marginTop: 8,
}}>
<Text style={{ fontSize: 12, fontWeight: 700, color: '#fff' }}>Total Due</Text>
<Text style={{ fontSize: 12, fontWeight: 700, color: '#fff' }}>
${total.toFixed(2)}
</Text>
</View>
</View>
</View>
</Page>
</Document>
);
}
Tables split across pages automatically. If you have 50 line items, the header row repeats on every page. No CSS hacks required.
The Worker
// src/index.ts
import { Hono } from 'hono';
import { formePdf } from '@formepdf/hono';
import Invoice from './templates/invoice';
type Bindings = {
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.use(formePdf());
// Create an invoice
app.post('/invoices', async (c) => {
const body = await c.req.json();
const id = crypto.randomUUID();
await c.env.DB.prepare(`
INSERT INTO invoices (id, company_name, company_address, client_name, client_company, client_email, invoice_number, date, due_date, items, tax_rate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
id,
body.companyName,
body.companyAddress,
body.clientName,
body.clientCompany,
body.clientEmail,
body.invoiceNumber,
body.date,
body.dueDate,
JSON.stringify(body.items),
body.taxRate ?? 0
).run();
return c.json({ id });
});
// Render invoice as PDF
app.get('/invoices/:id/pdf', async (c) => {
const invoice = await c.env.DB.prepare(
'SELECT * FROM invoices WHERE id = ?'
).bind(c.req.param('id')).first();
if (!invoice) {
return c.json({ error: 'Invoice not found' }, 404);
}
const data = {
companyName: invoice.company_name as string,
companyAddress: invoice.company_address as string,
clientName: invoice.client_name as string,
clientCompany: invoice.client_company as string,
invoiceNumber: invoice.invoice_number as string,
date: invoice.date as string,
dueDate: invoice.due_date as string,
items: JSON.parse(invoice.items as string),
taxRate: invoice.tax_rate as number,
};
return c.pdf(() => Invoice({ data }));
});
export default app;
Deploy
npx wrangler deploy
Test it
Create an invoice:
curl -X POST https://invoice-api.your-subdomain.workers.dev/invoices \
-H "Content-Type: application/json" \
-d '{
"companyName": "Acme Corp",
"companyAddress": "123 Business Ave, San Francisco, CA 94102",
"clientName": "Jane Smith",
"clientCompany": "Smith Corp",
"invoiceNumber": "INV-001",
"date": "2026-03-28",
"dueDate": "2026-04-28",
"items": [
{ "description": "Web Development", "quantity": 40, "unitPrice": 150 },
{ "description": "UI/UX Design", "quantity": 20, "unitPrice": 125 }
],
"taxRate": 0.08
}'
Render it as PDF:
curl https://invoice-api.your-subdomain.workers.dev/invoices/{id}/pdf \
--output invoice.pdf
Open invoice.pdf. A professional invoice, generated at the edge in under 50ms.
Why this works on Workers
Forme's engine is a Rust binary compiled to WASM. It has no native dependencies, doesn't touch the filesystem, and doesn't spawn processes. It takes a JSON document description and returns PDF bytes — pure computation.
The WASM binary is 3MB, well within Workers' bundle limits. The @formepdf/hono middleware detects the edge runtime automatically and uses the browser-compatible WASM loader instead of Node's fs module.
What's next
This API is stateless — it renders PDFs from D1 data on demand. From here you could:
- Add authentication with Cloudflare Access or a JWT middleware
- Store rendered PDFs in R2 and return a download URL instead of streaming bytes
- Add a webhook that fires when an invoice is created, triggering an email via Resend
- Use Forme's hosted dashboard to manage templates visually instead of editing JSX
The full Forme docs are at docs.formepdf.com.