2026/04/17

Node.js PDF生成完全ガイド|Express・サーバーレス・Honoで実装

Node.jsPDF生成Expressサーバーレスチュートリアル

「Node.jsでPDFを生成したい」という要件は、請求書・レポート・証明書・帳票など、あらゆるWebアプリで発生します。ライブラリを自前でインストールするか、APIを使うか、サーバーレスで動かすか――選択肢は複数あります。

この記事では、Node.jsプロジェクトでPDFを生成するすべての主要パターンを、実際に動くコード例とともに解説します。FUNBREW PDF APIを使う方法を中心に、Express・Hono・AWS Lambda・Cloudflare Workersでの実装まで幅広くカバーします。

Node.js でのPDF生成:4つのアプローチ比較

まず主要な手法を比較します。

アプローチ メリット デメリット おすすめ用途
PDF API(FUNBREW PDF) セットアップ不要・CSS完全対応・スケーラブル ネットワーク依存 本番環境・SaaS・高品質
Puppeteer(ローカル) 無料・細かい制御 重い(Chromium同梱)・サーバーレス不向き 開発・少量生成
PDFKit 軽量・ゼロ依存 HTML非対応・低水準API シンプルな帳票
jsPDF クライアントサイドも可 CSSレイアウト再現が困難 軽量なクライアント生成

複雑なHTMLレイアウト(グラフ・テーブル・日本語フォント)を含む業務PDFには PDF API が最も適しています。PDF APIとライブラリの比較で詳しく解説しています。

インストールと基本設定

# プロジェクト初期化
mkdir my-pdf-app && cd my-pdf-app
npm init -y
npm install axios dotenv

# .env ファイル作成
echo "FUNBREW_API_KEY=your-api-key" > .env
echo "FUNBREW_API_URL=https://api.pdf.funbrew.cloud/v1" >> .env

PDF生成の基本モジュール

// 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',
});

/**
 * HTMLからPDFバイナリを生成する
 * @param {string} html - PDF化するHTML文字列
 * @param {object} options - 用紙サイズ・余白・エンジンなどのオプション
 * @returns {Promise<Buffer>} - PDFのバイナリデータ
 */
async function htmlToPdf(html, options = {}) {
  const {
    engine = 'quality',   // 'quality'(Chromium)または 'fast'(wkhtmltopdf)
    format = 'A4',        // 'A4', 'Letter', 'Legal' など
    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 };

APIドキュメントでオプションの完全仕様を確認できます。

パターン1: Express アプリへの組み込み

最も一般的なパターン。既存のExpressアプリにPDF生成エンドポイントを追加します。

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
 * 請求書PDFを生成してレスポンスで返す
 */
app.post('/api/pdf/invoice', async (req, res) => {
  const { customerName, amount, items = [] } = req.body;

  if (!customerName || !amount) {
    return res.status(400).json({ error: 'customerName と amount は必須です' });
  }

  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生成エラー:', error.message);
    res.status(500).json({ error: 'PDF生成に失敗しました', detail: error.message });
  }
});

/**
 * GET /api/pdf/report/:type
 * レポートPDFをS3などのストレージに保存してURLを返す(非同期パターン)
 */
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' });

    // 実際のアプリではS3やGCSに保存して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('ja-JP');

  return `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: 'Noto Sans JP', 'Hiragino Sans', 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; }
    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; }
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
  </style>
</head>
<body>
  <h1>請求書</h1>
  <div class="meta">
    <p>請求番号: ${invoiceNumber}</p>
    <p>発行日: ${date}</p>
    <p>請求先: <strong>${customerName} 御中</strong></p>
  </div>
  <table>
    <thead>
      <tr><th>品目</th><th style="text-align:right">金額</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>サービス利用料</td><td style="text-align:right">¥${amount.toLocaleString()}</td></tr>`
      }
    </tbody>
  </table>
  <div class="total-section">
    <p>小計: ¥${amount.toLocaleString()}</p>
    <p>消費税 (10%): ¥${tax.toLocaleString()}</p>
    <p class="total-row">合計: ¥${total.toLocaleString()}</p>
  </div>
</body>
</html>`;
}

function buildReportHtml(type) {
  return `<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:40px;}h1{color:#2563eb;}</style>
</head><body>
<h1>${type === 'weekly' ? '週次' : '月次'}レポート</h1>
<p>生成日時: ${new Date().toLocaleString('ja-JP')}</p>
</body></html>`;
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`サーバー起動: http://localhost:${PORT}`));

module.exports = app;

curlでのテスト

# 請求書PDF生成テスト
curl -X POST http://localhost:3000/api/pdf/invoice \
  -H "Content-Type: application/json" \
  -d '{"customerName":"株式会社サンプル","amount":100000}' \
  -o invoice.pdf

# レポートPDF生成テスト
curl http://localhost:3000/api/pdf/report/monthly

パターン2: AWS Lambda(サーバーレス)

Puppeteerはサーバーレス環境でのコールドスタートが重い問題がありますが、PDF APIを使えば軽量な Lambda 関数でPDFを生成できます。

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

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

/**
 * Lambda ハンドラ
 * イベント例: { "type": "invoice", "customerId": "C001", "amount": 50000 }
 */
export const handler = async (event) => {
  const { type = 'invoice', customerId, amount } = event;

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

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

    // 署名付きURL(15分有効)を生成して返す
    const downloadUrl = await getSignedUrl(
      s3,
      new PutObjectCommand({ Bucket: BUCKET, Key: key }),
      { expiresIn: 900 }
    );

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

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

function buildHtml(type, data) {
  return `<!DOCTYPE html>
<html lang="ja"><head><meta charset="UTF-8">
<style>body{font-family:'Noto Sans JP',sans-serif;padding:40px;}</style>
</head><body>
<h1>${type === 'invoice' ? '請求書' : 'レポート'}</h1>
<p>顧客ID: ${data.customerId}</p>
<p>金額: ¥${(data.amount || 0).toLocaleString()}</p>
<p>生成日時: ${new Date().toLocaleString('ja-JP')}</p>
</body></html>`;
}
# serverless.yml(Serverless Frameworkを使う場合)
service: pdf-generator
provider:
  name: aws
  runtime: nodejs20.x
  region: ap-northeast-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

パターン3: Hono(Edge Runtime / Cloudflare Workers)

HonoはCloudflare Workers・Deno・Bun などのEdge Runtimeで動作する軽量フレームワークです。

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 と amount は必須です' }, 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生成失敗' }, 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="ja"><head><meta charset="UTF-8">
<style>body{font-family:sans-serif;padding:40px;color:#1e293b;}</style>
</head><body>
<h1 style="color:#2563eb">請求書</h1>
<p><strong>${customerName} 御中</strong></p>
<p>金額: ¥${amount.toLocaleString()}</p>
<p>消費税: ¥${tax.toLocaleString()}</p>
<p>合計: ¥${(amount + tax).toLocaleString()}</p>
</body></html>`;
}

export default app;

Cloudflare Workers での Hono は Puppeteer 非対応ですが、PDF APIを呼び出すだけなので完全に動作します。サーバーレス環境でのPDF生成も参照してください。

パターン4: HTMLテンプレートファイルからの生成

実際の業務では、HTMLテンプレートをファイルとして管理し、変数を差し込んでPDFを生成するパターンが主流です。

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

/**
 * テンプレートファイルにデータを差し込んでHTMLを生成する
 */
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="ja">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4; margin: 20mm 15mm; }
    * { box-sizing: border-box; }
    body {
      font-family: 'Noto Sans JP', 'Hiragino Sans', 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">請求書</div>
      <div>請求番号: {{invoiceNumber}}</div>
      <div>発行日: {{issueDate}}</div>
    </div>
    <div style="text-align:right">
      <strong>{{companyName}}</strong>
    </div>
  </div>

  <p><strong>{{customerName}} 御中</strong></p>

  <table>
    <thead>
      <tr><th>品目</th><th>数量</th><th>単価</th><th>小計</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}}</div>
    <div>消費税 (10%): ¥{{tax}}</div>
    <div style="font-size:22px; color:#2563eb">合計: ¥{{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',
    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('ja-JP'),
    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 };

PDFテンプレートエンジンガイドでより高度なテンプレート設計パターンを解説しています。

エラーハンドリングと本番環境対応

リトライとタイムアウト

// src/pdf-client.js — 本番対応版
const axiosRetry = require('axios-retry').default;

axiosRetry(client, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    // 5xx エラーとネットワークエラーのみリトライ
    return axiosRetry.isNetworkOrIdempotentRequestError(error)
      || (error.response?.status >= 500);
  },
  onRetry: (retryCount, error) => {
    console.warn(`[PDF] リトライ ${retryCount}回目: ${error.message}`);
  },
});
npm install axios-retry

ストリーミングレスポンス(大きなPDF向け)

// 大きなPDFはストリームで返す
app.get('/api/pdf/large-report', async (req, res) => {
  const html = buildLargeReportHtml();

  const response = await client.post('/pdf/from-html', { html, engine: 'quality' }, {
    responseType: 'stream',  // arraybuffer ではなく stream
  });

  res.set('Content-Type', 'application/pdf');
  res.set('Content-Disposition', 'attachment; filename="report.pdf"');
  response.data.pipe(res);
});

PDF生成のパフォーマンス最適化

// src/pdf-queue.js — キューで並行数を制御
const { default: PQueue } = require('p-queue');

// 同時PDF生成を最大5件に制限
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}件待機, ${queue.pending}件処理中`);
});

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

詳細な本番環境最適化はPDF API本番運用チェックリストを参照してください。

よくある質問

Node.js で最も簡単にPDFを生成するには?

FUNBREW PDF APIを使う方法が最も簡単です。axiosで数行のHTTPリクエストを書くだけで、複雑なHTMLをPDFに変換できます。Puppeteerのようなブラウザのインストールが不要で、サーバーレス環境でも動作します。

PuppeteerでPDFを生成する場合の問題点は?

Puppeteerはローカル開発には便利ですが、本番環境では以下の問題が発生することがあります。(1) Chromiumバイナリ(~300MB)のデプロイ管理、(2) AWS LambdaやCloud Functionsでのコールドスタート増加、(3) メモリ使用量の増加、(4) Dockerイメージサイズの増大。これらを回避するためにPDF APIを使う開発者が増えています。

Node.js v18以降で変わった点は?

Node.js v18以降では組み込みのfetchAPIが使えるため、axiosなどの外部HTTPライブラリなしでPDF APIを呼び出せます。

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' }),
});
const pdfBuffer = Buffer.from(await response.arrayBuffer());

日本語PDFが文字化けする場合は?

HTMLの<meta charset="UTF-8">の記述と、CSSのfont-familyに'Noto Sans JP'を指定することで解決します。FUNBREW PDFはNoto Sans JPをプリインストール済みのため、追加フォントのアップロードは不要です。詳しくは日本語フォントガイドを参照してください。

まとめ

Node.jsでのPDF生成パターンをまとめます。

パターン 実装 特徴
Express API POST /pdf エンドポイント 同期レスポンス・即時ダウンロード
Lambda / サーバーレス Event-driven + S3 スケーラブル・コスト効率
Edge Runtime(Hono) Cloudflare Workers対応 低レイテンシ・グローバル配信
テンプレートファイル Mustache/Handlebars 保守性・デザイナー対応

いずれのパターンでも、PDF生成の実体はFUNBREW PDF APIへのHTTPリクエストです。プレイグラウンドでHTMLテンプレートをすぐに試せます。

関連記事

Powered by FUNBREW PDF