NaN/NaN/NaN

Next.jsやNuxt.jsでWebアプリを開発しているとき、「ページをPDFで出力したい」「請求書や証明書をダウンロードさせたい」という要件はよく発生します。クライアントサイドだけでPDFを生成しようとすると、CSSの再現性や文字化け、ファイルサイズの問題がつきまといます。

そこで有効なのが、サーバーサイドでHTMLからPDFを生成するAPIを使う方法です。この記事ではFUNBREW PDFをNext.js(App Router)とNuxt 3に統合する手順を、実践的なコード例とともに解説します。

フレームワーク非依存の基本的なHTMLからPDF生成の概念はHTML→PDF変換 完全ガイドを、APIの基本的な使い方は言語別クイックスタートを参照してください。TypeScriptでの型安全なAPI利用についてはTypeScript×PDF APIガイドで詳しく解説しています。

なぜフロントエンドフレームワークでPDF APIが必要か

ブラウザのネイティブ印刷機能(window.print())やjsPDFなどのクライアントサイドライブラリには限界があります。

課題 詳細
CSS再現性の低さ ブラウザごとに印刷スタイルが異なる
動的コンテンツ 認証が必要なデータや外部APIデータを含められない
ファイルサイズ クライアントでの変換は処理が重くなりやすい
セキュリティ APIキーをクライアントに渡せない

サーバーサイドでPDFを生成することで、これらの問題をすべて回避できます。Next.jsのAPI RouteやNuxt 3のServer Routeはこの用途に最適です。wkhtmltopdfとChromiumの違いについてはエンジン比較記事も参考にしてください。

共通の準備

APIキーの取得

ダッシュボードからFUNBREW PDFのAPIキーを取得します。環境変数に保存し、クライアントコードには絶対に含めないでください。

APIキーの安全な管理方法はセキュリティガイドで詳しく解説しています。

# .env.local
FUNBREW_PDF_API_KEY=sk-your-api-key

Node.js SDKのインストール

npm install @funbrew/pdf

SDKを使わず直接HTTPリクエストを送る場合はネイティブのfetchが使えます(Node.js 18以降)。

Next.js(App Router)編

プロジェクト構成

app/
├── api/
│   └── generate-pdf/
│       └── route.ts        # PDF生成エンドポイント
├── invoice/
│   └── page.tsx            # 請求書ページ
└── components/
    └── DownloadButton.tsx  # PDFダウンロードボタン

API Route の実装

app/api/generate-pdf/route.ts にサーバーサイドのPDF生成エンドポイントを作成します。

// app/api/generate-pdf/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { html, options } = body

    // 入力バリデーション
    if (!html || typeof html !== 'string') {
      return NextResponse.json(
        { error: 'html フィールドは必須です' },
        { status: 400 }
      )
    }

    const pdfBuffer = await client.generate({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        ...options,
      },
    })

    return new NextResponse(pdfBuffer, {
      status: 200,
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': 'attachment; filename="document.pdf"',
      },
    })
  } catch (error) {
    console.error('PDF生成エラー:', error)
    return NextResponse.json(
      { error: 'PDF生成に失敗しました' },
      { status: 500 }
    )
  }
}

SDKを使わずに直接APIを呼び出す場合は次のように書けます。

// fetch を使った実装例
const response = await fetch('https://api.funbrew.dev/api/v1/generate', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ html, options }),
})

if (!response.ok) {
  throw new Error(`API エラー: ${response.status}`)
}

const pdfBuffer = await response.arrayBuffer()

テンプレート変数の差し込み

動的データをHTMLに埋め込んでPDFを生成するケースが多いでしょう。サーバーサイドでデータを取得し、HTMLテンプレートに変数を差し込む方法を示します。

// app/api/generate-pdf/invoice/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

function buildInvoiceHtml(data: {
  invoiceNumber: string
  customerName: string
  items: Array<{ name: string; quantity: number; price: number }>
  total: number
}): string {
  const rows = data.items
    .map(
      (item) => `
      <tr>
        <td>${item.name}</td>
        <td>${item.quantity}</td>
        <td>¥${item.price.toLocaleString()}</td>
        <td>¥${(item.quantity * item.price).toLocaleString()}</td>
      </tr>`
    )
    .join('')

  return `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; }
    h1 { color: #1a56db; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th, td { border: 1px solid #e5e7eb; padding: 10px; text-align: left; }
    th { background: #f9fafb; }
    .total { font-size: 1.2em; font-weight: bold; text-align: right; margin-top: 20px; }
  </style>
</head>
<body>
  <h1>請求書 #${data.invoiceNumber}</h1>
  <p>請求先: ${data.customerName} 様</p>
  <table>
    <thead>
      <tr><th>品目</th><th>数量</th><th>単価</th><th>小計</th></tr>
    </thead>
    <tbody>${rows}</tbody>
  </table>
  <p class="total">合計: ¥${data.total.toLocaleString()}</p>
</body>
</html>`
}

export async function POST(request: NextRequest) {
  const invoiceData = await request.json()
  const html = buildInvoiceHtml(invoiceData)

  const pdfBuffer = await client.generate({
    html,
    options: { format: 'A4' },
  })

  return new NextResponse(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoiceData.invoiceNumber}.pdf"`,
    },
  })
}

テンプレートエンジンを使った高度な差し込み方法はテンプレートエンジン活用ガイドで解説しています。

クライアントサイドからのダウンロード

クライアントコンポーネントからAPI Routeを叩き、PDFをダウンロードさせます。

// app/components/DownloadButton.tsx
'use client'

import { useState } from 'react'

interface InvoiceData {
  invoiceNumber: string
  customerName: string
  items: Array<{ name: string; quantity: number; price: number }>
  total: number
}

export function DownloadPdfButton({ invoiceData }: { invoiceData: InvoiceData }) {
  const [loading, setLoading] = useState(false)

  const handleDownload = async () => {
    setLoading(true)
    try {
      const response = await fetch('/api/generate-pdf/invoice', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(invoiceData),
      })

      if (!response.ok) {
        throw new Error('PDF生成に失敗しました')
      }

      // Blob に変換してダウンロードリンクを作成
      const blob = await response.blob()
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `invoice-${invoiceData.invoiceNumber}.pdf`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
    } catch (error) {
      console.error(error)
      alert('PDFのダウンロードに失敗しました')
    } finally {
      setLoading(false)
    }
  }

  return (
    <button
      onClick={handleDownload}
      disabled={loading}
      className="btn btn-primary"
    >
      {loading ? '生成中...' : 'PDFをダウンロード'}
    </button>
  )
}

Server Componentからの直接生成

App RouterのServer Componentでは、API Routeを経由せずに直接PDF生成ロジックを呼び出すこともできます。

// app/invoice/[id]/download/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'
import { getInvoiceById } from '@/lib/db'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  // 認証チェック(セッション/JWT等)
  // const session = await getServerSession()
  // if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const invoice = await getInvoiceById(params.id)
  if (!invoice) {
    return NextResponse.json({ error: '請求書が見つかりません' }, { status: 404 })
  }

  const html = buildInvoiceHtml(invoice)
  const pdfBuffer = await client.generate({ html, options: { format: 'A4' } })

  return new NextResponse(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `inline; filename="invoice-${params.id}.pdf"`,
    },
  })
}

Nuxt 3編

プロジェクト構成

server/
├── api/
│   └── generate-pdf.post.ts   # PDF生成エンドポイント
└── utils/
    └── pdf.ts                 # 共通ユーティリティ
pages/
└── invoice.vue               # 請求書ページ
composables/
└── usePdf.ts                 # PDF操作コンポーザブル

Server Route の実装

Nuxt 3のServer Routeはファイル名の末尾に.post.tsをつけることでPOSTメソッドに限定できます。

// server/api/generate-pdf.post.ts
import { FunbrewPdf } from '@funbrew/pdf'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const { html, options, filename = 'document.pdf' } = body

  if (!html || typeof html !== 'string') {
    throw createError({
      statusCode: 400,
      statusMessage: 'html フィールドは必須です',
    })
  }

  const apiKey = useRuntimeConfig().funbrewPdfApiKey
  const client = new FunbrewPdf({ apiKey })

  try {
    const pdfBuffer = await client.generate({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        ...options,
      },
    })

    setResponseHeaders(event, {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${filename}"`,
    })

    return pdfBuffer
  } catch (error) {
    throw createError({
      statusCode: 500,
      statusMessage: 'PDF生成に失敗しました',
    })
  }
})

nuxt.config.ts — runtimeConfig 設定

APIキーはサーバー専用のruntimeConfigに設定します。publicに入れるとクライアントに露出するので注意してください。

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // サーバーサイドのみ(環境変数 NUXT_FUNBREW_PDF_API_KEY で上書き可能)
    funbrewPdfApiKey: process.env.FUNBREW_PDF_API_KEY ?? '',
    public: {
      // ここはクライアントに公開される
    },
  },
})
# .env
NUXT_FUNBREW_PDF_API_KEY=sk-your-api-key

コンポーザブルの実装

再利用可能なPDFダウンロード処理をコンポーザブルにまとめます。

// composables/usePdf.ts
export function usePdf() {
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function downloadPdf(
    html: string,
    options?: Record<string, unknown>,
    filename = 'document.pdf'
  ) {
    loading.value = true
    error.value = null

    try {
      const response = await $fetch<Blob>('/api/generate-pdf', {
        method: 'POST',
        body: { html, options, filename },
        responseType: 'blob',
      })

      const url = URL.createObjectURL(response)
      const a = document.createElement('a')
      a.href = url
      a.download = filename
      a.click()
      URL.revokeObjectURL(url)
    } catch (err) {
      error.value = 'PDFのダウンロードに失敗しました'
      console.error(err)
    } finally {
      loading.value = false
    }
  }

  return { loading, error, downloadPdf }
}

Nuxt 3 ページコンポーネント

<!-- pages/invoice.vue -->
<script setup lang="ts">
const { loading, error, downloadPdf } = usePdf()

const invoiceData = ref({
  number: 'INV-2026-001',
  customer: '株式会社サンプル',
  total: 150000,
})

async function handleDownload() {
  const html = `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>body { font-family: sans-serif; padding: 40px; }</style>
</head>
<body>
  <h1>請求書 #${invoiceData.value.number}</h1>
  <p>請求先: ${invoiceData.value.customer} 様</p>
  <p>合計: ¥${invoiceData.value.total.toLocaleString()}</p>
</body>
</html>`

  await downloadPdf(html, { format: 'A4' }, `invoice-${invoiceData.value.number}.pdf`)
}
</script>

<template>
  <div>
    <h1>請求書管理</h1>
    <p v-if="error" class="text-red-500">{{ error }}</p>
    <button
      :disabled="loading"
      @click="handleDownload"
      class="btn btn-primary"
    >
      {{ loading ? '生成中...' : 'PDFをダウンロード' }}
    </button>
  </div>
</template>

エラーハンドリング

PDF生成APIを呼び出す際は、適切なエラーハンドリングが不可欠です。詳細はエラーハンドリングガイドを参照してください。

リトライロジック(Next.js)

// lib/pdf.ts
import { FunbrewPdf } from '@funbrew/pdf'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

export async function generatePdfWithRetry(
  html: string,
  options = {},
  maxRetries = 3
): Promise<ArrayBuffer> {
  let lastError: Error | undefined

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await client.generate({ html, options })
    } catch (error) {
      lastError = error as Error
      if (attempt < maxRetries) {
        // 指数バックオフ
        await new Promise((resolve) => setTimeout(resolve, 1000 * attempt))
      }
    }
  }

  throw lastError
}

HTTPステータスコード別の対応

ステータスコード 意味 対応
400 HTMLの形式が不正 リクエストを修正する
401 APIキー不正 環境変数を確認する
429 レート制限超過 リトライ間隔を空ける
500 サーバーエラー リトライする

本番デプロイ時の注意点

Vercelへのデプロイ(Next.js)

Vercelでは環境変数をプロジェクト設定から追加します。APIキーをクライアントに露出させないために、NEXT_PUBLIC_プレフィックスは絶対に使わないでください。

# Vercel CLI で環境変数を設定
vercel env add FUNBREW_PDF_API_KEY production

タイムアウト設定: PDF生成は場合によって数秒かかります。Vercelの無料プランではServerless Functionのデフォルトタイムアウトは10秒です。大きなドキュメントを扱う場合はProプラン(最大60秒)が必要です。

// app/api/generate-pdf/route.ts
export const maxDuration = 60 // 秒(Pro プランのみ)

Netlify へのデプロイ(Nuxt 3)

Nuxt 3はNetlifyのEdge FunctionsやServerless Functionsで動作します。

# netlify.toml
[functions]
  node_bundler = "esbuild"
  external_node_modules = ["@funbrew/pdf"]
# Netlify CLI で環境変数を設定
netlify env:set NUXT_FUNBREW_PDF_API_KEY "sk-your-api-key"

セルフホストの場合

VPS等でNext.js/Nuxtをセルフホストする場合も基本的なセキュリティ対策は同じです。

  • APIキーは環境変数で管理する(.envファイルはGitに含めない)
  • レート制限をかける(ddos防止)
  • PDFのサイズ制限を設ける(メモリ保護)

サーバーレス環境でのPDF生成についてはサーバーレスPDF生成ガイドも参考にしてください。また本番運用の包括的なチェックリストは本番運用チェックリストをご覧ください。Docker/Kubernetes環境での運用はDocker & Kubernetesガイドで解説しています。CSS印刷スタイルの最適化はPDF出力向けCSSレイアウトのコツが参考になります。

一括PDF生成

複数のドキュメントを一度に生成する場合は、バッチ処理を活用します。

// app/api/generate-pdfs-batch/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { FunbrewPdf } from '@funbrew/pdf'

const client = new FunbrewPdf({
  apiKey: process.env.FUNBREW_PDF_API_KEY!,
})

export async function POST(request: NextRequest) {
  const { documents } = await request.json()

  // 並列処理(最大5件)
  const chunks = []
  for (let i = 0; i < documents.length; i += 5) {
    chunks.push(documents.slice(i, i + 5))
  }

  const results = []
  for (const chunk of chunks) {
    const pdfs = await Promise.allSettled(
      chunk.map((doc: { html: string; filename: string }) =>
        client.generate({ html: doc.html, options: { format: 'A4' } })
      )
    )
    results.push(...pdfs)
  }

  // 成功/失敗のサマリーを返す
  const summary = {
    total: results.length,
    success: results.filter((r) => r.status === 'fulfilled').length,
    failed: results.filter((r) => r.status === 'rejected').length,
  }

  return NextResponse.json(summary)
}

大量ドキュメントの一括生成については一括生成ガイドで詳しく解説しています。

まとめ

Next.js(App Router)とNuxt 3でのFUNBREW PDF API統合のポイントをまとめます。

  • APIキーは必ずサーバーサイドのみで扱う(NEXT_PUBLIC_runtimeConfig.publicに入れない)
  • API Route / Server RouteでPDF生成ロジックをカプセル化する
  • クライアントコンポーネントからはBlobダウンロードパターンを使う
  • Vercel / Netlifyへのデプロイ時はタイムアウト設定に注意する
  • エラーハンドリングとリトライを実装して安定性を確保する

プレイグラウンドでHTMLテンプレートを試してから実装を始めると、開発が効率的に進みます。ユースケース別の活用例はユースケース一覧もご参照ください。

請求書PDFの自動化については請求書PDF自動化ガイド、証明書PDFの生成については証明書PDF自動化ガイドもあわせてご覧ください。MarkdownからのPDF生成はMarkdown→PDF APIガイド、月次レポートの自動生成はレポートPDF生成ガイドも参考になります。利用量に応じたプラン選択は料金プラン比較をご確認ください。

Powered by FUNBREW PDF