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

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.

GitHub | Docs