Next.jsで「診断結果をPDFでダウンロードしたい」「請求書をブラウザから生成したい」という要件が出たとき、候補になるのがjsPDFと@react-pdf/rendererです。
本記事では、実際にSEO診断ツールのPDFレポート機能を実装した経験をもとに、@react-pdf/rendererを使った日本語PDF生成の方法を、SVGチャート描画のハマりどころや解決策とあわせて解説します。
jsPDF vs @react-pdf/renderer — どちらを選ぶべきか
まず2つのライブラリの特徴を比較します。
| jsPDF | @react-pdf/renderer | |
|---|---|---|
| レイアウト | 座標を直接指定 | FlexboxベースのReactコンポーネント |
| 日本語対応 | addFontでBase64変換が必要 | Font.registerでパス指定できる |
| TypeScript | 型定義が薄め | 充実している |
| 複雑なレイアウト | 座標管理がつらい | コンポーネントに分割できる |
| ファイルサイズ | 小さめ | やや大きめ |
jsPDFは「表紙・サマリー・テーブル・チャートを組み合わせた複数ページ構成」になると、y座標を手動で管理し続ける必要があります。また日本語フォントを埋め込むにはttfをArrayBufferとして読み込んでBase64に変換する処理が必要です。
一方、@react-pdf/rendererはReactコンポーネントとして書けること、Font.registerがシンプルなこと、Flexboxでレイアウトできることが強みです。複数ページのレポートやダッシュボード型PDFには特に向いています。
セットアップ — インストールとフォント登録
インストール
npm install @react-pdf/renderer
# または
pnpm add @react-pdf/renderer日本語フォントファイルを配置する
日本語フォント(NotoSansJP)を public/fonts/ に配置します。Google Fonts からttfをダウンロードするか、@fontsource/noto-sans-jp のnpmパッケージからコピーしてもOKです。
public/
fonts/
NotoSansJP-Medium.ttfフォント登録関数を作る
フォント登録は複数回呼ばれると無駄なので、登録済みフラグで制御します。path.join(process.cwd(), ...) を使うのがポイントで、相対パスだとサーバーサイドで動かしたときにパスが解決できずエラーになります。
// lib/pdf/register-fonts.ts
import { Font } from "@react-pdf/renderer"
import path from "path"
let registered = false
export function registerFonts() {
if (registered) return
Font.register({
family: "NotoSansJP",
src: path.join(process.cwd(), "public/fonts/NotoSansJP-Medium.ttf"),
})
registered = true
}基本的なPDFコンポーネントの書き方
@react-pdf/rendererはDOMではなく独自のコンポーネント体系を持ちます。主なコンポーネントは以下の通りです。
| コンポーネント | 役割 |
|---|---|
| Document | PDF全体のルート |
| Page | 1ページ分。size=”A4″ でA4サイズ |
| View | divのような箱。Flexboxで配置 |
| Text | テキスト表示。fontFamily指定必須 |
| StyleSheet.create | スタイル定義 |
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"
const styles = StyleSheet.create({
page: {
fontFamily: "NotoSansJP",
fontSize: 10,
padding: 40,
backgroundColor: "#FFFFFF",
},
heading: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 12,
},
})
export function SampleDocument() {
return (
<Document title="サンプルPDF">
<Page size="A4" style={styles.page}>
<View>
<Text style={styles.heading}>SEO診断レポート</Text>
<Text>診断結果を日本語で表示できます。</Text>
</View>
</Page>
</Document>
)
}fontFamily: "NotoSansJP" をページレベルで指定しておくと、子要素全体に継承されます。
SVGチャート描画のハマりどころと解決策
スコアをビジュアルに表示するために、SVGで円形のプログレスチャートを実装しようとして、2つの大きな壁にぶつかりました。
問題1: SVGのTextでtextAnchorやfontSizeがtype errorになる
@react-pdf/rendererにはSvg、Circle、Path、Text等のSVGコンポーネントが用意されています。しかし、SVG内の Text は通常のPDF用 Text と同名なのでimportが競合します。aliasを付けても textAnchor や fontSize をpropsに渡すと型エラーになります。
解決策: SVGの上にView/Textを position: “absolute” で重ねる
SVGには図形だけを描いて、テキストの配置はReactPDFのコンポーネント側で行う方法が最もスッキリします。
import { View, Text, Svg, Circle } from "@react-pdf/renderer"
export function ScoreCircle({ score, maxScore, size = 100 }: Props) {
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
<Svg width={size} height={size} style={{ position: "absolute" }}>
<Circle cx={size / 2} cy={size / 2} r={size / 2 - 8}
stroke="#E2E8F0" strokeWidth={6} fill="none" />
</Svg>
<Text style={{ fontSize: size * 0.28, fontWeight: "bold" }}>{score}</Text>
<Text style={{ fontSize: size * 0.12, color: "#64748B" }}>/ {maxScore}</Text>
</View>
)
}問題2: CircleでstrokeDashoffsetが使えない
通常のSVGでは strokeDasharray と strokeDashoffset の組み合わせで円弧を描きますが、@react-pdf/rendererの Circle は strokeDashoffset が型定義に存在せずTypeエラーになります。
解決策: Path + describeArc関数で円弧を直接描く
三角関数で始点と終点の座標を計算して、SVGのArcコマンドでパスを生成します。
function describeArc(
cx: number,
cy: number,
r: number,
startAngle: number,
endAngle: number
): string {
const x1 = cx + r * Math.cos(startAngle)
const y1 = cy + r * Math.sin(startAngle)
const x2 = cx + r * Math.cos(endAngle)
const y2 = cy + r * Math.sin(endAngle)
const largeArc = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}
この関数を使ってスコアに応じた円弧を描画します。
export function ScoreCircle({ score, maxScore, size = 100 }: Props) {
const cx = size / 2
const cy = size / 2
const r = size / 2 - 8
const pct = maxScore > 0 ? score / maxScore : 0
const color = pct >= 0.8 ? "#22C55E" : pct >= 0.6 ? "#F59E0B" : "#EF4444"
const startAngle = -Math.PI / 2
const endAngle = startAngle + 2 * Math.PI * pct
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
<Circle cx={cx} cy={cy} r={r} stroke="#E2E8F0" strokeWidth={6} fill="none" />
{pct > 0 && (
<Path
d={describeArc(cx, cy, r, startAngle, endAngle)}
stroke={color}
strokeWidth={6}
fill="none"
strokeLinecap="round"
/>
)}
</Svg>
<Text style={{ fontSize: size * 0.28, fontWeight: "bold" }}>{score}</Text>
<Text style={{ fontSize: size * 0.12, color: "#64748B" }}>/ {maxScore}</Text>
</View>
)
}ゲージチャート(半円)の場合は、startAngleを Math.PI、endAngleを 0 にすれば下半分をくり抜いた半円になります。ただしSVGの座標系ではy軸が反転するため、describeArc関数の符号を変える必要がある点に注意してください。
API Route経由でPDFを配信する
Next.js App RouterのAPI Routeからサーバーサイドでバイナリを返す実装です。
// app/api/generate-pdf/route.ts
import { NextResponse } from "next/server"
import { renderToBuffer } from "@react-pdf/renderer"
import { registerFonts } from "@/lib/pdf/register-fonts"
import { ReportDocument } from "@/lib/pdf/report-document"
export async function POST(request: Request) {
try {
const body = await request.json()
registerFonts()
const buffer = await renderToBuffer(
ReportDocument({ results: body.results, date: body.date })
)
const uint8 = new Uint8Array(buffer)
return new NextResponse(uint8, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="report.pdf"`,
},
})
} catch (error) {
console.error("PDF generation failed:", error)
return NextResponse.json(
{ error: "PDF generation failed" },
{ status: 500 }
)
}
}renderToBuffer の戻り値は Buffer ですが、NextResponse に渡すには new Uint8Array(buffer) への変換が必要です。この一行が抜けると、バイナリが正しく送信されず壊れたPDFが返ってきます。
クライアント側のダウンロード処理
async function downloadPDF(data: ReportData) {
const response = await fetch("/api/generate-pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error("PDF生成に失敗しました")
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "seo-report.pdf"
a.click()
URL.revokeObjectURL(url)
}実際のPDF出力構成例
今回の実装では、以下の5ページ構成のPDFレポートが生成されます。
- 表紙ページ: ロゴ・タイトル・URL・診断日時
- エグゼクティブサマリー: 総合スコア(円形チャート)、カテゴリ別スコアカード
- チェック項目一覧: 全診断項目の結果テーブル(good/warning/badのステータス付き)
- 改善提案: badとwarningの項目を優先度付きでリストアップ
- Core Web Vitals: LCP・CLS・INPのゲージチャート、モバイル/デスクトップ比較
各ページはコンポーネントに分割されているため、ページを増減させたいときは Document に子コンポーネントを追加・削除するだけで対応できます。これが@react-pdf/rendererを選んだ最大の恩恵です。
まとめ
@react-pdf/rendererを選ぶメリット
- コンポーネントで分割できるため複数ページ構成でも管理しやすい
- FlexboxでレイアウトできるのでCSSの知識がそのまま活きる
- 日本語フォント登録が Font.register 1行で済む
- TypeScriptの型が充実している
実装時の注意点
| 問題 | 解決策 |
|---|---|
| SVGのTextでtextAnchor等がtype error | View/Textオーバーレイで分離する |
| strokeDashoffsetが型定義にない | Path + describeArc関数で円弧を自前で描く |
| renderToBufferの戻り値がNextResponseに渡せない | new Uint8Array(buffer)で変換する |
| Font.registerのパスが解決できない | process.cwd()で絶対パスを指定する |
SVGの制約はやや面倒ですが、三角関数でパスを計算する方法に慣れると自由度は高くなります。複数ページのレポートや日本語を含むPDF生成が必要なNext.jsプロジェクトでは、@react-pdf/rendererが有力な選択肢です。
Q. jsPDFと@react-pdf/rendererはどう使い分けますか?
単純な1ページPDF(請求書の控えなど)ならjsPDFで十分です。複数ページ構成やチャート・テーブルを含むレポート型PDFなら@react-pdf/rendererの方がコンポーネント分割できて管理しやすくなります。
Q. @react-pdf/rendererでクライアントサイドレンダリングはできますか?
可能ですが、Font.registerのパス解決がサーバーサイドと異なります。クライアントサイドではフォントファイルのURLを直接指定する必要があります。サーバーサイドでrenderToBufferを使い、API Route経由で配信する方法が最も安定します。
Q. describeArc関数を使わずに円弧を描く方法はありますか?
@react-pdf/rendererのSVGサポートが改善されればstrokeDashoffsetが使えるようになる可能性がありますが、現時点(2026年4月)では型定義に含まれていません。describeArcによるPath描画が最も確実な方法です。
