← Back to blog

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 D1
  • GET /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.