はじめに
今回試したソースコードはこちらで確認いただけます。
やってみたこと
正しい表現か自信はありませんが、ニュアンスを感じ取っていただければ幸いです。
- クライアントサイドでPDFをレンダリングする(CSR)
- サーバーサイドでPDFを生成して、クライアントサイドからは埋め込みで閲覧する(SSR)
- API RouteのレスポンスとしてPDFファイルを表示する
今回検証に使用したツール類のバージョンはこちらです。
Name | Version |
---|---|
M2 MacBook Air 2022 | 14.6.1 |
Google Chrome | 129.0.6668.70 |
Node.js | 20.11.0 |
npm | 10.2.4 |
@react-pdf/renderer | 4.0.0 |
next | 14.2.13 |
react | 18.3.1 |
typescript | 5.6.2 |
環境構築
まずは Next.js のテンプレートからリポジトリを作成します。
- Biomeを使ってみたかったのでESLintはNo
- 簡単にお試しするだけなので、UtilityFirstなTailwindを選択
- AppRouterは今回使いませんのでNo
- その他は適当です
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@14.2.13
Ok to proceed? (y) y
✔ What is your project named? … react-pdf-with-next-pages-router
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … No
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/user/workspace/react-pdf-with-next-pages-router.
依存ライブラリを最新化しておく
npm i react@latest react-dom@latest next@latest typescript@latest \
@types/node@latest @types/react@latest @types/react-dom@latest \
postcss@latest tailwindcss@latest
package.json
↓
"dependencies": {
"next": "^14.2.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@biomejs/biome": "1.9.2",
"@types/node": "^22.6.1",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2"
}
Biome導入
検証ついでに以前から気になっていたBiomeを使ってみます。ESLint、Prettier と高い互換性を持つツールチェーンです。
ライブラリを導入してinitします。
npm install --save-dev --save-exact @biomejs/biome
npx @biomejs/biome init
package.json
内のscriptsを編集します。
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint",
+ "lint": "biome lint",
+ "format": "biome format",
+ "check": "biome check --write"
},
Cursor(VSCode)拡張機能のインストール
# VSCode
code --install-extension biomejs.biome
# Cursor
cursor --install-extension biomejs.biome
.vscode/settings.json
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}
まだコード量が少ないので、速度に関してはESLint+Prettierとの差を体感することはできませんでした。ただ、使い勝手は全然変わらないですね。乗り換えのハードルはかなり低そうです。
react-pdf
npm install @react-pdf/renderer --save
PDFを出力してみる
PDFDocumentコンポーネントを作っておく
src/components/PDFDocument.tsx
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { StyleSheet } from "@react-pdf/renderer";
// Create styles
export const styles = StyleSheet.create({
page: {
fontFamily: "HackGen35",
flexDirection: "row",
backgroundColor: "#E4E4E4",
},
section: {
margin: 10,
padding: 10,
flexGrow: 1,
},
boldText: {
fontWeight: "bold",
},
});
/**
* Create Document Component
*/
const PDFDocument = () => (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.section}>
<Text>Section #1</Text>
<Text>こんにちは</Text>
</View>
<View style={styles.section}>
<Text>Section #2</Text>
<Text style={styles.boldText}>太字です</Text>
</View>
</Page>
</Document>
);
export default PDFDocument;
日本語が文字化けすることへの対処
Fontを登録してあげないと、日本語文字が文字化けする可能性があります。
公式ドキュメントでは TTFフォントファイルのみサポートしている記述がありますが、Issue Commentを見る限りWOFFファイルも対応しているようです。
今回はプログラミング向けフォントとして公開されている 白源 (はくげん/HackGen) を使ってみました。
なお、 public
フォルダ内の静的Assetにアクセスするパスが処理が動く環境によって異なるようで、PDFレンダリング処理ごとに設定を呼び分けています。(詳細は各コードを参照)
リンクボタンを作っておく(任意)
src/pages/index.tsx
に以下を追加しておきます。これでTopページから各コンポーネントにアクセスしやすくなります
<div className="gap-2 flex">
<Link href={"/client"}>
<Button>Client</Button>
</Link>
<Link href={"/server"}>
<Button>Server</Button>
</Link>
<Link href={"/api/pdf"}>
<Button>/api/pdf</Button>
</Link>
</div>
Client Side でPDFをレンダリング
src/pages/client/index.tsx
"use client";
import PDFDocument from "@/components/PDFDocument";
import { Font } from "@react-pdf/renderer";
import dynamic from "next/dynamic";
Font.register({
family: "HackGen35",
fonts: [
{
src: "/fonts/HackGen35-Regular.ttf",
fontStyle: "normal",
fontWeight: "normal",
},
{
src: "/fonts/HackGen35-Bold.ttf",
fontStyle: "normal",
fontWeight: "bold",
},
],
});
const DynamicPDFViewer = dynamic(
() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer),
{
loading: () => <p>Loading...</p>,
ssr: false,
}
);
/**
* Client Side でPDFをレンダリングする
*/
const Page = () => {
return (
<>
<p>クライアントサイドで作成したPDFを表示しています。</p>
<DynamicPDFViewer className="mx-auto" width={1200} height={1000}>
<PDFDocument />
</DynamicPDFViewer>
</>
);
};
export default Page;
http://localhost:3000/client にアクセスするとPDFがレンダリングされて表示されます。
Lazy Loading
(Doc)のssr
optionをfalseとすることで、サーバーでのレンダリングを抑制しフロントで処理させています。
@react-pdf/renderer
からexportされている PDFViewer
コンポーネントは内部的にWebAPIを使用しているようで、Node.js環境で実行するとエラーが発生します。
Server Side でPDFを生成
src/server/pdf-font.ts
import path from "node:path";
import { Font } from "@react-pdf/renderer";
/**
* サーバーサイドからpublicフォルダ内の静的ファイルにアクセスするための絶対パスを取得する
*/
export const getPublicAssetPath = (fileName: string): string =>
path.resolve("./public", fileName);
Font.register({
family: "HackGen35",
fonts: [
{
src: getPublicAssetPath("fonts/HackGen35-Regular.ttf"),
fontStyle: "normal",
fontWeight: "normal",
},
{
src: getPublicAssetPath("fonts/HackGen35-Bold.ttf"),
fontStyle: "normal",
fontWeight: "bold",
},
],
});
src/server/makePDF.tsx
import "@/server/pdf-font";
import PDFDocument from "@/components/PDFDocument";
import { getPublicAssetPath } from "@/server/pdf-font";
import * as ReactPDF from "@react-pdf/renderer";
/**
* サーバーサイドでPDFを生成し、publicフォルダにファイルとして出力する
*/
export const makePDF = async () => {
const pdfFileName = "generated-in-server.pdf";
await ReactPDF.renderToFile(<PDFDocument />, getPublicAssetPath(pdfFileName));
return { fileName: pdfFileName, url: `/${pdfFileName}` };
};
src/pages/server/index.tsx
import { makePDF } from "@/server/makePDF";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
const Page = ({
fileName,
fileURL,
}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
return (
<>
<p>サーバーサイドで作成したPDFを表示しています。</p>
<object
className="mx-auto"
title={fileName}
data={fileURL}
type="application/pdf"
width={1000}
height={1200}
/>
</>
);
};
export const getServerSideProps = (async () => {
const { fileName, url } = await makePDF();
return { props: { fileName, fileURL: url } };
}) satisfies GetServerSideProps<{ fileName: string; fileURL: string }>;
export default Page;
http://localhost:3000/server にアクセスするとPDFがレンダリングされて表示されます。clientの時と見た目はほぼ一緒ですが、PDFファイルの生成がサーバーサイドで行われている点が異なります。
getServerSideProps
からは JSON serializable data types
しかreturnできないっぽいので、ファイルのStreamやBufferをコンポーネントpropsに渡すことは出来なさそうでした。
API RouteのレスポンスとしてPDFファイルを表示する
src/pages/api/pdf.tsx
import PDFDocument from "@/components/PDFDocument";
import ReactPDF from "@react-pdf/renderer";
import type { NextApiRequest, NextApiResponse } from "next";
import "@/server/pdf-font";
export default async function handler(
_req: NextApiRequest,
res: NextApiResponse<never>
) {
const stream = await ReactPDF.renderToStream(<PDFDocument />);
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
'inline; filename="react-pdf-sample.pdf"'
);
stream
.pipe(res)
.on("end", () => console.log("Done streaming, response sent."));
}
Streamを使用してレスポンスします。
http://localhost:3000/api/pdf にアクセスすると、サーバーで生成されたPDFがフルスクリーンで表示されます。
おわりに
クライアントサイド、サーバーサイドを選ばずPDF生成できるのでかなり便利だなと思いました。
React-pdf が公開するPDFのComponentsやStylingに関しては検証できていないので、また時間がある時に触ってみようと思います。
参考