April 17, 2026

Node PDF API Guide: Express, Lambda & Edge with FUNBREW PDF

Node.jsPDF generationExpressserverlesstutorial

Generating PDFs in Node.js comes up constantly — invoices, reports, certificates, contracts. But picking the right approach matters. Should you spin up Puppeteer? Use a library like PDFKit? Or call a PDF API?

This guide covers every major Node.js PDF generation pattern with working code: Express endpoints, AWS Lambda, Hono on Cloudflare Workers, and file-based HTML templates. All examples use the FUNBREW PDF API — no Chromium binary required.

Node.js PDF Generation: Four Approaches Compared

Approach Pros Cons Best For
PDF API (FUNBREW PDF) No setup, full CSS support, scalable Network dependency Production, SaaS, high-quality output
Puppeteer (local) Free, fine-grained control Heavy (~300MB binary), bad for serverless Development, low volume
PDFKit Lightweight, zero dependencies No HTML support, low-level API Simple documents
jsPDF Works client-side Hard to reproduce CSS layouts Lightweight client generation

For complex HTML layouts — charts, tables, CJK fonts — a PDF API is the most practical approach. See the PDF API vs library comparison for a detailed breakdown.

Installation and Base Setup

# Initialize project
mkdir my-pdf-app && cd my-pdf-app
npm init -y
npm install axios dotenv

# Create .env
echo "FUNBREW_API_KEY=your-api-key" > .env
echo "FUNBREW_API_URL=https://api.pdf.funbrew.cloud/v1" >> .env

Core PDF Client Module

// src/pdf-client.js
require('dotenv').config();
const axios = require('axios');

const client = axios.create({
  baseURL: process.env.FUNBREW_API_URL || 'https://api.pdf.funbrew.cloud/v1',
  headers: {
    Authorization: `Bearer ${process.env.FUNBREW_API_KEY}`,
    'Content-Type': 'application/json',
  },
  timeout: 30000,
  responseType: 'arraybuffer',
});

/**
 * Convert HTML string to a PDF Buffer
 * @param {string} html - HTML to convert
 * @param {object} options - Paper size, margins, engine, etc.
 * @returns {Promise<Buffer>}
 */
async function htmlToPdf(html, options = {}) {
  const {
    engine = 'quality',   // 'quality' (Chromium) or 'fast' (wkhtmltopdf)
    format = 'A4',        // 'A4', 'Letter', 'Legal', etc.
    landscape = false,
    margin = { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    displayHeaderFooter = false,
    headerTemplate = '',
    footerTemplate = '',
  } = options;

  const response = await client.post('/pdf/from-html', {
    html,
    engine,
    format,
    landscape,
    margin,
    displayHeaderFooter,
    headerTemplate,
    footerTemplate,
  });

  return Buffer.from(response.data);
}

module.exports = { htmlToPdf };

See the API docs for the full list of options.

Pattern 1: Express Integration

The most common pattern — adding a PDF endpoint to an existing Express app.

npm install express
// src/app.js
require('dotenv').config();
const express = require('express');
const { htmlToPdf } = require('./pdf-client');

const app = express();
app.use(express.json());

/**
 * POST /api/pdf/invoice
 * Generate an invoice PDF and stream it as the response
 */
app.post('/api/pdf/invoice', async (req, res) => {
  const { customerName, amount, items = [] } = req.body;

  if (!customerName || !amount) {
    return res.status(400).json({ error: 'customerName and amount are required' });
  }

  try {
    const html = buildInvoiceHtml({ customerName, amount, items });
    const pdf = await htmlToPdf(html, {
      engine: 'quality',
      format: 'A4',
      margin: { top: '25mm', bottom: '20mm', left: '20mm', right: '20mm' },
    });

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${Date.now()}.pdf"`,
      'Content-Length': pdf.length,
    });
    res.send(pdf);

  } catch (error) {
    console.error('PDF generation error:', error.message);
    res.status(500).json({ error: 'PDF generation failed', detail: error.message });
  }
});

/**
 * GET /api/pdf/report/:type
 * Generate a report PDF and return metadata (async storage pattern)
 */
app.get('/api/pdf/report/:type', async (req, res) => {
  const { type } = req.params;

  try {
    const html = buildReportHtml(type);
    const pdf = await htmlToPdf(html, {
      format: 'A4',
      landscape: type === 'chart',
    });

    // In production: upload to S3/GCS and return a download URL
    const filename = `report-${type}-${Date.now()}.pdf`;
    // await uploadToS3(pdf, filename);

    res.json({
      success: true,
      size: pdf.length,
      filename,
      // downloadUrl: `https://your-bucket.s3.amazonaws.com/${filename}`,
    });

  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

function buildInvoiceHtml({ customerName, amount, items }) {
  const tax = Math.floor(amount * 0.1);
  const total = amount + tax;
  const invoiceNumber = `INV-${Date.now()}`;
  const date = new Date().toLocaleDateString('en-US');

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, 'Segoe UI', sans-serif; color: #1e293b; padding: 40px; }
    h1 { font-size: 28px; color: #2563eb; margin-bottom: 8px; }
    .meta { color: #64748b; font-size: 13px; margin-bottom: 32px; }
    table { width: 100%; border-collapse: collapse; margin: 24px 0; font-size: 13px; }
    th { background: #f1f5f9; padding: 10px 12px; text-align: left; border-bottom: 2px solid #e2e8f0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
    .total-section { text-align: right; margin-top: 16px; }
    .total-row { font-size: 20px; font-weight: 700; color: #2563eb; }
  </style>
</head>
<body>
  <h1>Invoice</h1>
  <div class="meta">
    <p>Invoice #: ${invoiceNumber}</p>
    <p>Date: ${date}</p>
    <p>Bill To: <strong>${customerName}</strong></p>
  </div>
  <table>
    <thead>
      <tr><th>Item</th><th style="text-align:right">Amount</th></tr>
    </thead>
    <tbody>
      ${items.length > 0
        ? items.map(item => `<tr><td>${item.name}</td><td style="text-align:right">$${item.price.toLocaleString()}</td></tr>`).join('')
        : `<tr><td>Service fee</td><td style="text-align:right">$${amount.toLocaleString()}</td></tr>`
      }
    </tbody>
  </table>
  <div class="total-section">
    <p>Subtotal: $${amount.toLocaleString()}</p>
    <p>Tax (10%): $${tax.toLocaleString()}</p>
    <p class="total-row">Total: $${total.toLocaleString()}</p>
  </div>
</body>
</html>`;
}

function buildReportHtml(type) {
  return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:40px;}h1{color:#2563eb;}</style>
</head><body>
<h1>${type === 'weekly' ? 'Weekly' : 'Monthly'} Report</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
</body></html>`;
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));

module.exports = app;

Test with curl

# Test invoice generation
curl -X POST http://localhost:3000/api/pdf/invoice \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Acme Corp","amount":5000}' \
  -o invoice.pdf

# Test report generation
curl http://localhost:3000/api/pdf/report/monthly

Pattern 2: AWS Lambda (Serverless)

Puppeteer has well-known cold-start issues in Lambda due to the Chromium binary. With a PDF API, your Lambda function stays lightweight.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lambda/generate-pdf.mjs (ESM)
import { htmlToPdf } from './pdf-client.js';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
const BUCKET = process.env.S3_BUCKET_NAME;

/**
 * Lambda handler
 * Event: { "type": "invoice", "customerId": "C001", "amount": 5000 }
 */
export const handler = async (event) => {
  const { type = 'invoice', customerId, amount } = event;

  try {
    const html = buildHtml(type, { customerId, amount });
    const pdfBuffer = await htmlToPdf(html, { engine: 'quality', format: 'A4' });

    const key = `pdfs/${type}/${customerId}-${Date.now()}.pdf`;
    await s3.send(new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      Body: pdfBuffer,
      ContentType: 'application/pdf',
      ServerSideEncryption: 'AES256',
    }));

    // Pre-signed download URL (valid for 15 minutes)
    const downloadUrl = await getSignedUrl(
      s3,
      new GetObjectCommand({ Bucket: BUCKET, Key: key }),
      { expiresIn: 900 }
    );

    return {
      statusCode: 200,
      body: JSON.stringify({ success: true, key, downloadUrl }),
    };

  } catch (error) {
    console.error('Lambda PDF error:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: error.message }),
    };
  }
};

function buildHtml(type, data) {
  return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:40px;}</style>
</head><body>
<h1>${type === 'invoice' ? 'Invoice' : 'Report'}</h1>
<p>Customer ID: ${data.customerId}</p>
<p>Amount: $${(data.amount || 0).toLocaleString()}</p>
<p>Generated: ${new Date().toLocaleString()}</p>
</body></html>`;
}
# serverless.yml
service: pdf-generator
provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1
  environment:
    FUNBREW_API_KEY: ${ssm:/pdf-generator/api-key}
    FUNBREW_API_URL: https://api.pdf.funbrew.cloud/v1
    S3_BUCKET_NAME: !Ref PdfBucket
  iam:
    role:
      statements:
        - Effect: Allow
          Action: [s3:PutObject, s3:GetObject]
          Resource: !Sub arn:aws:s3:::${PdfBucket}/*

functions:
  generatePdf:
    handler: lambda/generate-pdf.handler
    timeout: 30
    events:
      - http:
          path: /pdf
          method: post
          cors: true

For more serverless patterns, see the PDF API serverless guide.

Pattern 3: Hono on Cloudflare Workers (Edge Runtime)

Hono is a lightweight framework that runs on Cloudflare Workers, Deno, Bun, and other Edge runtimes. Puppeteer can't run at the edge, but calling a PDF API works perfectly.

npm create hono@latest pdf-api-hono -- --template cloudflare-workers
cd pdf-api-hono && npm install
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';

type Env = {
  FUNBREW_API_KEY: string;
  FUNBREW_API_URL: string;
};

const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());

app.post('/pdf/invoice', async (c) => {
  const { customerName, amount } = await c.req.json<{
    customerName: string;
    amount: number;
  }>();

  if (!customerName || !amount) {
    return c.json({ error: 'customerName and amount are required' }, 400);
  }

  const html = buildInvoiceHtml(customerName, amount);

  const response = await fetch(`${c.env.FUNBREW_API_URL}/pdf/from-html`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${c.env.FUNBREW_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      engine: 'quality',
      format: 'A4',
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    }),
  });

  if (!response.ok) {
    return c.json({ error: 'PDF generation failed' }, 500);
  }

  const pdfBuffer = await response.arrayBuffer();
  return new Response(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${Date.now()}.pdf"`,
    },
  });
});

function buildInvoiceHtml(customerName: string, amount: number): string {
  const tax = Math.floor(amount * 0.1);
  return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:40px;color:#1e293b;}</style>
</head><body>
<h1 style="color:#2563eb">Invoice</h1>
<p><strong>Bill To: ${customerName}</strong></p>
<p>Subtotal: $${amount.toLocaleString()}</p>
<p>Tax: $${tax.toLocaleString()}</p>
<p><strong>Total: $${(amount + tax).toLocaleString()}</strong></p>
</body></html>`;
}

export default app;

Pattern 4: File-Based HTML Templates

In real projects, HTML templates are managed as files with variable substitution.

npm install mustache  # or handlebars, ejs
// src/template-renderer.js
const fs = require('fs');
const path = require('path');
const Mustache = require('mustache');

function renderTemplate(templateName, data) {
  const templatePath = path.join(__dirname, '../templates', `${templateName}.html`);
  const template = fs.readFileSync(templatePath, 'utf-8');
  return Mustache.render(template, data);
}

module.exports = { renderTemplate };
<!-- templates/invoice.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4; margin: 20mm 15mm; }
    * { box-sizing: border-box; }
    body {
      font-family: -apple-system, 'Segoe UI', sans-serif;
      font-size: 12px;
      color: #1e293b;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
    .header { display: flex; justify-content: space-between; border-bottom: 3px solid #2563eb; padding-bottom: 16px; margin-bottom: 24px; }
    .title { font-size: 28px; font-weight: 700; color: #2563eb; }
    table { width: 100%; border-collapse: collapse; margin-top: 16px; }
    th { background: #dbeafe; padding: 10px; text-align: left; }
    td { padding: 10px; border-bottom: 1px solid #e2e8f0; }
    .total { text-align: right; margin-top: 16px; font-size: 18px; font-weight: 700; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="title">Invoice</div>
      <div>Invoice #: {{invoiceNumber}}</div>
      <div>Date: {{issueDate}}</div>
    </div>
    <div style="text-align:right">
      <strong>{{companyName}}</strong>
    </div>
  </div>

  <p><strong>Bill To: {{customerName}}</strong></p>

  <table>
    <thead>
      <tr><th>Item</th><th>Qty</th><th>Unit Price</th><th>Subtotal</th></tr>
    </thead>
    <tbody>
      {{#items}}
      <tr>
        <td>{{name}}</td>
        <td style="text-align:right">{{quantity}}</td>
        <td style="text-align:right">${{price}}</td>
        <td style="text-align:right">${{subtotal}}</td>
      </tr>
      {{/items}}
    </tbody>
  </table>

  <div class="total">
    <div>Subtotal: ${{subtotal}}</div>
    <div>Tax (10%): ${{tax}}</div>
    <div style="font-size:22px; color:#2563eb">Total: ${{total}}</div>
  </div>
</body>
</html>
// src/generate-invoice.js
const { renderTemplate } = require('./template-renderer');
const { htmlToPdf } = require('./pdf-client');

async function generateInvoicePdf(invoiceData) {
  const { customerName, companyName = 'FUNBREW Inc.', items = [] } = invoiceData;

  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const tax = Math.floor(subtotal * 0.1);
  const total = subtotal + tax;

  const html = renderTemplate('invoice', {
    invoiceNumber: `INV-${Date.now()}`,
    issueDate: new Date().toLocaleDateString('en-US'),
    customerName,
    companyName,
    items: items.map(item => ({
      ...item,
      price: item.price.toLocaleString(),
      subtotal: (item.price * item.quantity).toLocaleString(),
    })),
    subtotal: subtotal.toLocaleString(),
    tax: tax.toLocaleString(),
    total: total.toLocaleString(),
  });

  return htmlToPdf(html, {
    engine: 'quality',
    format: 'A4',
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  });
}

module.exports = { generateInvoicePdf };

For more advanced templating patterns (loops, conditionals, partials), see the PDF template engine guide.

Error Handling and Production Readiness

Retry with Exponential Backoff

// src/pdf-client.js — production-ready version
const axiosRetry = require('axios-retry').default;

axiosRetry(client, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    // Retry on network errors and 5xx responses only
    return axiosRetry.isNetworkOrIdempotentRequestError(error)
      || (error.response?.status >= 500);
  },
  onRetry: (retryCount, error) => {
    console.warn(`[PDF] Retry attempt ${retryCount}: ${error.message}`);
  },
});
npm install axios-retry

Using Node.js Built-in fetch (v18+)

Node.js v18 ships with a built-in fetch API, so no external HTTP library is needed:

async function htmlToPdfFetch(html, options = {}) {
  const response = await fetch(`${process.env.FUNBREW_API_URL}/pdf/from-html`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.FUNBREW_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html, engine: 'quality', format: 'A4', ...options }),
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: response.statusText }));
    throw new Error(`PDF API error ${response.status}: ${error.message}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

Concurrency Control with p-queue

// src/pdf-queue.js — limit concurrent PDF jobs
const { default: PQueue } = require('p-queue');

// Cap at 5 simultaneous PDF generations
const queue = new PQueue({ concurrency: 5 });

async function generatePdfQueued(html, options) {
  return queue.add(() => htmlToPdf(html, options));
}

queue.on('active', () => {
  console.log(`[Queue] ${queue.size} waiting, ${queue.pending} active`);
});

module.exports = { generatePdfQueued };
npm install p-queue

For full production guidance, see the PDF API production checklist.

Frequently Asked Questions

What is the easiest way to generate PDFs in Node.js?

Call the FUNBREW PDF API with a simple HTTP POST request. A few lines of axios or the built-in fetch is all you need — no Chromium binary to manage, and it works in every Node.js runtime including serverless and edge environments.

What are the downsides of using Puppeteer for PDF generation?

Puppeteer is convenient for local development, but in production it causes: (1) a ~300MB Chromium binary in your deployment, (2) longer cold starts in AWS Lambda or Cloud Functions, (3) higher memory usage, and (4) larger Docker images. PDF APIs eliminate all these issues.

Does this work with Node.js v18+ native fetch?

Yes. Node.js v18+ includes a built-in fetch — no axios required. The example in the "Error Handling" section shows the native fetch pattern.

How do I generate PDFs with Japanese or CJK fonts?

Include <meta charset="UTF-8"> in your HTML and set font-family: 'Noto Sans JP', sans-serif; in CSS. FUNBREW PDF ships with Noto Sans JP pre-installed, so no additional font uploads are needed. See the Japanese font guide for details.

How do I add page numbers to the PDF?

Use the displayHeaderFooter: true option with a footerTemplate in your API request, or use the CSS @page rule with @bottom-center { content: counter(page) "/" counter(pages); }. See the CSS tips guide for full CSS examples.

Summary

Pattern Use Case
Express endpoint Synchronous response — immediate PDF download
Lambda + S3 Event-driven — scalable async generation
Hono (Edge) Cloudflare Workers — low-latency global delivery
File-based templates Production apps — maintainable, designer-friendly

In all cases, the actual PDF generation is a single HTTP call to the FUNBREW PDF API. Test your HTML templates instantly in the Playground.

Related

Powered by FUNBREW PDF