← Back to blog

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 });

Using Forme with Cloudflare Workers

@formepdf/hono automatically detects whether it's running in Node.js or an edge runtime like Cloudflare Workers. In Node.js, it loads the WASM binary from the filesystem. In Workers and other edge environments, it switches to @formepdf/core/browser which loads WASM via fetch() — no fs or import.meta.url required.

This means the same code deploys to both environments with zero configuration. The API you wrote above works on wrangler dev locally and wrangler deploy to production without any changes.

If you need to provide a custom WASM URL (e.g., from a KV binding or R2 bucket), you can pre-initialize the engine:

import { init } from '@formepdf/core/browser';

// Call once before any renders
await init('https://cdn.example.com/forme_bg.wasm');

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
  • Deploys to Cloudflare Workers, Node.js, Deno, or Bun
  • 20-40ms render times

No Puppeteer. No Chrome. No separate render server. Just a Hono app and a WASM engine.

GitHub | Docs