Build a PDF API in 5 minutes
A complete PDF generation API with Hono. Authentication, multiple templates, custom documents. Deploy to Cloudflare Workers or any Node.js host.
Let's build a real PDF API. Not a demo, not a proof of concept. An API with auth, multiple templates, error handling, and deployment. Five minutes.
Setup
mkdir pdf-api && cd pdf-api
npm init -y
npm install hono @formepdf/hono @formepdf/react @formepdf/core
npm install -D typescript wrangler @types/node
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@formepdf/react",
"outDir": "dist",
"strict": true
}
}
The API
Create src/index.ts:
import { Hono } from 'hono';
import { bearerAuth } from 'hono/bearer-auth';
import { formePdf } from '@formepdf/hono';
const app = new Hono();
// Auth
app.use('/api/*', bearerAuth({ token: 'your-secret-token' }));
// PDF middleware
app.use(formePdf());
// Health check
app.get('/', (c) => c.json({ status: 'ok', templates: ['invoice', 'receipt', 'report', 'letter', 'shipping-label'] }));
// Render any built-in template
app.post('/api/render/:template', async (c) => {
const template = c.req.param('template');
const data = await c.req.json();
return c.pdf(template, data, {
filename: `${template}-${Date.now()}.pdf`,
});
});
// Force download
app.post('/api/download/:template', async (c) => {
const template = c.req.param('template');
const data = await c.req.json();
return c.pdf(template, data, {
filename: `${template}-${Date.now()}.pdf`,
download: true,
});
});
export default app;
That's the entire API. Any built-in template, any data, authenticated.
Test it
npx wrangler dev src/index.ts
In another terminal:
curl -X POST http://localhost:8787/api/render/invoice \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-d '{
"invoiceNumber": "INV-001",
"date": "March 1, 2026",
"dueDate": "March 31, 2026",
"company": {
"name": "Acme Corp",
"initials": "AC",
"address": "123 Main St",
"cityStateZip": "Portland, OR 97201",
"email": "billing@acme.com"
},
"billTo": {
"name": "Jane Smith",
"company": "Smith Industries",
"address": "456 Oak Ave",
"cityStateZip": "Seattle, WA 98101",
"email": "jane@smith.co"
},
"items": [
{ "description": "Web Development", "quantity": 40, "unitPrice": 150 },
{ "description": "Design", "quantity": 10, "unitPrice": 200 }
],
"taxRate": 0.08,
"paymentTerms": "Net 30"
}' --output invoice.pdf
Open invoice.pdf. That's a real invoice with line items, tax calculation, and professional formatting.
Add a custom template
Create src/templates/proposal.tsx:
import { Document, Page, View, Text } from '@formepdf/react';
interface ProposalData {
company: string;
client: string;
project: string;
date: string;
sections: { title: string; body: string; price: string }[];
total: string;
}
export function Proposal(data: ProposalData) {
return (
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 32, fontWeight: 700, color: '#1a365d' }}>
Proposal
</Text>
<Text style={{ fontSize: 14, color: '#666', marginTop: 8 }}>
{data.company} for {data.client}
</Text>
<Text style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{data.date}
</Text>
<View style={{ marginTop: 40 }}>
<Text style={{ fontSize: 20, fontWeight: 700 }}>
{data.project}
</Text>
</View>
{data.sections.map((section) => (
<View style={{ marginTop: 24 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 16, fontWeight: 700 }}>{section.title}</Text>
<Text style={{ fontSize: 16, color: '#1a365d' }}>{section.price}</Text>
</View>
<Text style={{ fontSize: 12, color: '#444', marginTop: 8, lineHeight: 1.6 }}>
{section.body}
</Text>
</View>
))}
<View style={{ marginTop: 40, borderTopWidth: 2, borderTopColor: '#1a365d', paddingTop: 16, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 24, fontWeight: 700, color: '#1a365d' }}>
Total: {data.total}
</Text>
</View>
</Page>
</Document>
);
}
Add the route:
import { Proposal } from './templates/proposal';
import { pdfResponse } from '@formepdf/hono';
app.post('/api/render/proposal', async (c) => {
const data = await c.req.json();
return pdfResponse(() => Proposal(data), {
filename: `proposal-${Date.now()}.pdf`,
});
});
Now POST /api/render/proposal with your proposal data and get a formatted PDF back.
Deploy
To Cloudflare Workers:
# wrangler.toml
name = "pdf-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"
npx wrangler deploy
Your PDF API is now running at the edge in 200+ data centers.
To any Node.js host, add a serve entry:
import { serve } from '@hono/node-server';
serve({ fetch: app.fetch, port: 3000 });
What you have
In five minutes:
- A PDF API with bearer token auth
- 5 built-in templates (invoice, receipt, report, letter, shipping label)
- A custom proposal template
- Inline preview or forced download
- Edge deployment ready
- 20-40ms render times
No Puppeteer. No Chrome. No separate render server. Just a Hono app and a WASM engine.