はじめに
最近、Next.jsでFirestoreをバックエンドとしたPublicサイトの開発を進めていて、どのようにFirestoreのデータを取得するか、色々と調査検討してみたので、その結果をまとめます。
開発の方針は以下の通りです。
- 1日1回、Firestoreの配信データが更新され、そのデータを閲覧できるサイト (多少ラグがあって良い)
- Firestoreは極力無料枠に収めたい。そのためにリクエスト回数を抑制したい。
- Publicサイトのため、SEOを考慮しNext.jsのサーバサイドレンダリングを活用する。
Next.jsでFirestoreからデータを取得する方法
Next.jsでFirestoreからデータを取得する方法は、いくつか考えられます。
- Client Componentで直接Firestoreから取得
- Route Handlerを使ってAPI化し、Server Componentからfetchする。
- Server Componentで直接Firestoreから取得
今回は、この中で「3. Server Componentで直接Firestoreから取得」を選択しました。それぞれの選択した/しなかった理由をこれから書いていきます。
Client Componentで直接Firestoreから取得
以下のような理由で選択しませんでした。
- Client ComponentでのレンタリングはSEO的に不利
- 更新頻度に対してFirestore側へのAPIコール数が過剰になってしまう
- セキュリティルールの設定が必須
Firestoreのリアルタイム性を活用した機能を実装するのであれば、この選択が良いと思います。
Route Handlerを使ってAPI化し、Server Componentからfetchする。
Client Componentから直接取得する方法に比べ、
- Server Componentでレンダリングすることにより、SEO的に有利
- Server Componentで閉じているため、Admin SDKを使用することができ、セキュリティルールは全てのread writeを無効化
- fetchにrevalidateを指定することで、Firestore側へのAPIコールを減らすことができる
といった点で、当初はこの方法を選択しようと思いましたが、以下のような理由で選択しませんでした。
- APIは公開されるため、API Key等による認証が必要になる
- APIごとにキャッシュ時間をカスタムできるのが利点だが、今のところそこまで細かく制御したいニーズがない
Server Componentで直接Firestoreから取得
Pages RouterのISRと同じような概念として、App Routerには Server ComponentのConfigに
revalidate
があります。初回アクセス時にレンダリングされた内容はキャッシュされ、
revalidate
に指定した秒数が経過して初めてのリクエスト時にキャッシュが更新されます。(ただし、すぐ最新化される訳ではなく、2回目以降のリクエストから最新化されます。)
revalidate
はそのページ全体をキャッシュします。複数のコンポーネントで
revalidate
が指定されている場合は、それらの中で一番短い時間が使用されます。(参考)
なお、デフォルトでは
false
となっていて、一度レンダリングされると、ずっとキャッシュが維持されます。
今回はシンプルな一覧と詳細がメイン機能であるため、この方法を選択しました。
(ちなみに、ページ単位のrevalidateのみでは柔軟性に欠けますが、これとfetchのrevalidationも組み合わせることができ、その場合は特定コンポーネントのみrevalidation間隔を短くすることができます。)
実際に組み込んでみる
Firestoreのセットアップ
詳細な説明は省きますが、Web Appの追加と、Firestoreへの初期データ登録までは完了させておきます。
また、サービス アカウントの認証情報を含む構成ファイル(JSON)をダウンロードし、src/の直下に置いておきます。
Firebase Adminの組み込み
まずは、firebase-adminをインストールします。
1 | % pnpm add firebase-admin |
次に、firebaseの初期化処理を書くため、
app/lib/firestore.ts
を作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import admin from "firebase-admin"; import serviceAccount from "@/yourprojectname-firebase-adminsdk-abcde-01234567.json"; import { getFirestore } from "firebase-admin/firestore"; const init = () => { if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ projectId: serviceAccount.project_id, privateKey: serviceAccount.private_key, clientEmail: serviceAccount.client_email, }), }); } }; |
ここでは、先ほど配置した構成ファイル(JSON)をimportし、Admin SDKの初期化時に認証情報を渡しています。
Firestoreからデータを取得する部分の実装
先ほどと同じファイルに追記していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | type Meeting = { id: string; title: string; agenda: string; summary: string; tags: string[]; heldAt: Date; }; export const getMeetings = async () => { init(); const db = getFirestore(); const meetingsRef = db.collection("meetings"); const snapshot = await meetingsRef.orderBy("heldAt", "desc").get(); return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data(), heldAt: doc.data().heldAt.toDate(), })) as Meeting[]; }; |
ここでは、議事録の一覧を取得して返しています。
page.tsxの実装
最後に、先ほど作った
getMeetings()
をpages.tsxから呼び、実際に表示させます。また、
export const revalidate
を宣言し、適切な値を設定することで、定期的にこのページを更新しつつ、Firestoreへのリクエストを抑えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | import { getMeetings } from "@/app/lib/firestore"; import { FormattedDate } from "@/components/formattedDate"; import Link from "next/link"; export const revalidate = 43200; // 12 hours export default async function Home() { const meetings = await getMeetings(); return ( <main> {meetings.map((meeting) => ( <div key={meeting.id}> <Link href={`/meetings/${meeting.id}`}> <h2>{meeting.title}</h2> </Link> <p>{meeting.agenda}</p> <p>{meeting.summary}</p> <p>{meeting.tags.join(", ")}</p> <p> <FormattedDate date={meeting.heldAt} /> </p> </div> ))} </main> ); } |
このように、Server Component内でFirestoreへのリクエストを完結させることで、クライアント側にその部分を隠蔽することができます。
さいごに
Server Componentのrevalidateを使用することで、Pages Router時代のISRと同じような挙動を作り出すことができました。